diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ae9064c51..9a4133be1c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,21 +5,53 @@ on: pull_request: jobs: - build: - runs-on: ubuntu-latest + build: + runs-on: ubuntu-latest + steps: + - name: Checkout project + uses: actions/checkout@v4 - steps: - - uses: actions/checkout@v2 - - name: Set up JDK 1.8 - uses: actions/setup-java@v1 - with: - java-version: '1.8' - - name: Build and run unit tests with Gradle - run: ./scripts/ci_unit.sh - - name: Publish to Sonatype - env: - NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }} - NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }} - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == 'bumptech/glide' }} - run: ./gradlew uploadArchives -PNEXUS_USERNAME="${NEXUS_USERNAME}" -PNEXUS_PASSWORD="${NEXUS_PASSWORD}" + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "zulu" + java-version: "17" + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run Gradle Build + run: | + ./gradlew build \ + -x :library:test:testDebugUnitTest \ + :library:test:assembleDebugUnitTest \ + -x :library:testDebugUnitTest \ + :library:assembleDebugUnitTest \ + -x :annotation:ksp:test:testDebugUnitTest \ + :annotation:ksp:test:assembleDebugUnitTest \ + -x :third_party:disklrucache:testDebugUnitTest \ + :third_party:disklrucache:assembleDebugUnitTest \ + -x :integration:cronet:testDebugUnitTest \ + :integration:cronet:assembleDebugUnitTest \ + -x :integration:gifencoder:testDebugUnitTest \ + :integration:gifencoder:assembleDebugUnitTest \ + -x :integration:ktx:testDebugUnitTest \ + :integration:ktx:assembleDebugUnitTest \ + -x :integration:concurrent:testDebugUnitTest \ + :integration:concurrent:assembleDebugUnitTest \ + -x :integration:volley:testDebugUnitTest \ + :integration:volley:assembleDebugUnitTest \ + -x :integration:sqljournaldiskcache:testDebugUnitTest \ + :integration:sqljournaldiskcache:assembleDebugUnitTest \ + -x :third_party:gif_decoder:testDebugUnitTest \ + :third_party:gif_decoder:assembleDebugUnitTest \ + :samples:flickr:build \ + :samples:giphy:build \ + :samples:contacturi:build \ + :samples:gallery:build \ + :samples:imgur:build \ + :samples:svg:build \ + :instrumentation:assembleAndroidTest \ + :benchmark:assembleAndroidTest \ + :glide:releaseJavadoc \ + --parallel diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml deleted file mode 100644 index 405a2b3065..0000000000 --- a/.github/workflows/gradle-wrapper-validation.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: "Validate Gradle Wrapper" -on: [push, pull_request] - -jobs: - validation: - name: "Validation" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: gradle/wrapper-validation-action@v1 diff --git a/.github/workflows/publish-manual.yml b/.github/workflows/publish-manual.yml new file mode 100644 index 0000000000..7bf8b05aa7 --- /dev/null +++ b/.github/workflows/publish-manual.yml @@ -0,0 +1,28 @@ +name: Publish to Maven (manual) + +on: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout project + uses: actions/checkout@v4 + - name: Make Gradle wrapper executable + run: chmod +x ./gradlew + - uses: actions/setup-java@v4 + with: + distribution: "zulu" + java-version: "17" + - name: Build and publish everything to Maven Central + # This can be improved in Gradle + run: ./gradlew :mocks:publishToMavenCentral :annotation:publishToMavenCentral :annotation:compiler:publishToMavenCentral :library:publishToMavenCentral :integration:sqljournaldiskcache:publishToMavenCentral :annotation:ksp:publishToMavenCentral :integration:recyclerview:publishToMavenCentral :integration:avif:publishToMavenCentral :integration:okhttp:publishToMavenCentral :integration:gifencoder:publishToMavenCentral :integration:ktx:publishToMavenCentral :integration:okhttp4:publishToMavenCentral :integration:volley:publishToMavenCentral :integration:concurrent:publishToMavenCentral :integration:cronet:publishToMavenCentral :integration:okhttp3:publishToMavenCentral :integration:compose:publishToMavenCentral :third_party:disklrucache:publishToMavenCentral :third_party:gif_decoder:publishToMavenCentral + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.MAVEN_SIGNING_KEY_ID }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_SIGNING_PRIVATE_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_SIGNING_PRIVATE_KEY_PASSWORD }} + ORG_GRADLE_PROJECT_mavenCentralPublishing: true + ORG_GRADLE_PROJECT_mavenCentralAutomaticPublishing: false diff --git a/README.md b/README.md index 47cca31bbc..828f484b58 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Glide ===== -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.github.bumptech.glide/glide/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.github.bumptech.glide/glide) [![Build Status](https://travis-ci.org/bumptech/glide.svg?branch=master)](https://travis-ci.org/bumptech/glide) +[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.github.bumptech.glide/glide/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.github.bumptech.glide/glide) | [View Glide's documentation][20] | [简体中文文档][22] | [Report an issue with Glide][5] Glide is a fast and efficient open source media management and image loading framework for Android that wraps media @@ -26,13 +26,12 @@ Or use Gradle: ```gradle repositories { -  google() + google() mavenCentral() } dependencies { -  implementation 'com.github.bumptech.glide:glide:4.12.0' - annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0' + implementation 'com.github.bumptech.glide:glide:5.0.5' } ``` @@ -42,38 +41,15 @@ Or Maven: com.github.bumptech.glide glide - 4.12.0 - - - com.github.bumptech.glide - compiler - 4.12.0 - true + 5.0.5 ``` For info on using the bleeding edge, see the [Snapshots][17] docs page. -ProGuard +R8 / Proguard -------- -Depending on your ProGuard (DexGuard) config and usage, you may need to include the following lines in your proguard.cfg (see the [Download and Setup docs page][25] for more details): - -```pro --keep public class * implements com.bumptech.glide.module.GlideModule --keep class * extends com.bumptech.glide.module.AppGlideModule { - (...); -} --keep public enum com.bumptech.glide.load.ImageHeaderParser$** { - **[] $VALUES; - public *; -} --keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder { - *** rewind(); -} - -# for DexGuard only --keepresourcexmlelements manifest/application/meta-data@value=GlideModule -``` +The specific rules are [already bundled](library/proguard-rules.txt) into the aar which can be interpreted by R8 automatically How do I use Glide? ------------------- @@ -89,7 +65,7 @@ Simple use cases will look something like this: ... ImageView imageView = (ImageView) findViewById(R.id.my_image_view); - Glide.with(this).load("http://goo.gl/gEgYUd").into(imageView); + Glide.with(this).load("https://goo.gl/gEgYUd").into(imageView); } // For a simple image list: diff --git a/annotation/build.gradle b/annotation/build.gradle deleted file mode 100644 index d6a7f76df6..0000000000 --- a/annotation/build.gradle +++ /dev/null @@ -1,3 +0,0 @@ -apply plugin: 'java' - -apply from: "${rootProject.projectDir}/scripts/upload.gradle" \ No newline at end of file diff --git a/annotation/build.gradle.kts b/annotation/build.gradle.kts new file mode 100644 index 0000000000..d2101c300f --- /dev/null +++ b/annotation/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("java") +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} \ No newline at end of file diff --git a/annotation/compiler/build.gradle b/annotation/compiler/build.gradle deleted file mode 100644 index 453d9877a8..0000000000 --- a/annotation/compiler/build.gradle +++ /dev/null @@ -1,93 +0,0 @@ -import org.gradle.internal.jvm.Jvm -import proguard.gradle.ProGuardTask - -apply plugin: 'java' - -configurations { - // adapted from https://android.googlesource.com/platform/frameworks/testing/+/976c423/espresso/espresso-lib/build.gradle - // compileOnly dependencies will be repackaged, see rules in jarjar ant task below - jarjar -} - -dependencies { - // from https://code.google.com/archive/p/jarjar/downloads - jarjar files('libs/jarjar-1.4.jar') - - compileOnly "com.squareup:javapoet:${JAVAPOET_VERSION}" - compileOnly "com.google.auto.service:auto-service:${AUTO_SERVICE_VERSION}" - compileOnly "com.google.code.findbugs:jsr305:${JSR_305_VERSION}" - compile project(':annotation') - // This is to support com.sun.tools.javac.util.List, currently used in RootModuleGenerator. - compile files(Jvm.current().getToolsJar()) - annotationProcessor "com.google.auto.service:auto-service:${AUTO_SERVICE_VERSION}" -} - -// Make sure running `gradlew :annotation:compiler:check` actually does full quality control. -test.dependsOn ':annotation:compiler:test:test' - -def packagingFolder = file("${buildDir}/intermediates") -def repackagedJar = file("${packagingFolder}/repackaged.jar") -def proguardedJar = file("${packagingFolder}/proguarded.jar") - -task compiledJar(type: Jar, dependsOn: classes) { - destinationDir = packagingFolder - archiveName = 'compiled.jar' - from sourceSets.main.output -} - -// Repackage compileOnly dependencies to avoid namespace collisions. -task jarjar(dependsOn: [tasks.compiledJar, configurations.compileOnly]) { - // Set up inputs and outputs to only rebuild when necessary (code change, dependency change). - inputs.files compiledJar - inputs.files configurations.compileOnly - outputs.file repackagedJar - - doFirst { - ant { - taskdef name: 'jarjar', - classname: 'com.tonicsystems.jarjar.JarJarTask', - classpath: configurations.jarjar.asPath - - jarjar(jarfile: repackagedJar) { - configurations.compileOnly.resolve().each { - zipfileset(src: it.absolutePath, excludes: [ - 'META-INF/maven/**', - 'META-INF/services/javax.annotation.processing.Processor' - ].join(',')) - } - zipfileset(src: tasks.compiledJar.archivePath) - def repackageIntoGlide = 'com.bumptech.glide.repackaged.@0' - rule result: repackageIntoGlide, pattern: 'com.squareup.javapoet.**' - rule result: repackageIntoGlide, pattern: 'com.google.auto.**' - rule result: repackageIntoGlide, pattern: 'com.google.common.**' - rule result: repackageIntoGlide, pattern: 'com.google.thirdparty.publicsuffix.**' - } - } - } -} - -// Proguard repackaged dependencies to reduce the binary size. -task proguard(type: ProGuardTask, dependsOn: tasks.jarjar) { - configuration 'proguard.pro' - - injars repackagedJar - outjars proguardedJar - - libraryjars files(configurations.compile.collect()) - libraryjars "${System.getProperty('java.home')}/lib/rt.jar" -} - -// Replace the contents of the standard jar task with those from our our compiled, repackaged and -// proguarded jar. Replacing the task itself is possible and looks simpler, but requires -// reconstructing the task dependency chain and is more complex in practice. -jar { - dependsOn proguard - from zipTree(proguardedJar) - exclude { entry -> - sourceSets.main.output.files*.absolutePath.any { - entry.file.absolutePath.startsWith it - } - } -} - -apply from: "${rootProject.projectDir}/scripts/upload.gradle" diff --git a/annotation/compiler/build.gradle.kts b/annotation/compiler/build.gradle.kts new file mode 100644 index 0000000000..80ff26b674 --- /dev/null +++ b/annotation/compiler/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("java") +} + +dependencies { + implementation(libs.javapoet) + implementation(libs.guava) + + compileOnly(libs.autoservice) + compileOnly(libs.findbugs.jsr305) + + implementation(project(":annotation")) + annotationProcessor(libs.autoservice) +} + +tasks.withType { + isFailOnError = false +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") \ No newline at end of file diff --git a/annotation/compiler/libs/jarjar-1.4.jar b/annotation/compiler/libs/jarjar-1.4.jar deleted file mode 100644 index 68b9db9aa5..0000000000 Binary files a/annotation/compiler/libs/jarjar-1.4.jar and /dev/null differ diff --git a/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/ExtensionProcessor.java b/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/ExtensionProcessor.java index 0e0ebfd1d9..3d9c1bb220 100644 --- a/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/ExtensionProcessor.java +++ b/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/ExtensionProcessor.java @@ -21,10 +21,12 @@ final class ExtensionProcessor { ExtensionProcessor( ProcessingEnvironment processingEnvironment, ProcessorUtil processorUtil, - IndexerGenerator indexerGenerator) { + IndexerGenerator indexerGenerator, + boolean useLegacyTypeComparison) { this.processorUtil = processorUtil; this.indexerGenerator = indexerGenerator; - extensionValidator = new GlideExtensionValidator(processingEnvironment, processorUtil); + extensionValidator = + new GlideExtensionValidator(processingEnvironment, processorUtil, useLegacyTypeComparison); } boolean processExtensions(RoundEnvironment env) { diff --git a/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/GlideAnnotationProcessor.java b/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/GlideAnnotationProcessor.java index ad1e86d34f..4514dcab17 100644 --- a/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/GlideAnnotationProcessor.java +++ b/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/GlideAnnotationProcessor.java @@ -2,6 +2,7 @@ import com.bumptech.glide.annotation.GlideType; import com.google.auto.service.AutoService; +import com.google.common.collect.ImmutableSet; import java.util.HashSet; import java.util.Set; import javax.annotation.processing.AbstractProcessor; @@ -62,11 +63,13 @@ @AutoService(Processor.class) public final class GlideAnnotationProcessor extends AbstractProcessor { static final boolean DEBUG = false; + private static final String USE_LEGACY_TYPE_COMPARISON_OPTION = "glide.useLegacyTypeComparison"; private ProcessorUtil processorUtil; private LibraryModuleProcessor libraryModuleProcessor; private AppModuleProcessor appModuleProcessor; private boolean isGeneratedAppGlideModuleWritten; private ExtensionProcessor extensionProcessor; + private boolean useLegacyTypeComparison; @Override public synchronized void init(ProcessingEnvironment processingEnvironment) { @@ -75,8 +78,17 @@ public synchronized void init(ProcessingEnvironment processingEnvironment) { IndexerGenerator indexerGenerator = new IndexerGenerator(processorUtil); libraryModuleProcessor = new LibraryModuleProcessor(processorUtil, indexerGenerator); appModuleProcessor = new AppModuleProcessor(processingEnvironment, processorUtil); + useLegacyTypeComparison = + Boolean.parseBoolean( + processingEnvironment.getOptions().get(USE_LEGACY_TYPE_COMPARISON_OPTION)); extensionProcessor = - new ExtensionProcessor(processingEnvironment, processorUtil, indexerGenerator); + new ExtensionProcessor( + processingEnvironment, processorUtil, indexerGenerator, useLegacyTypeComparison); + } + + @Override + public Set getSupportedOptions() { + return ImmutableSet.of(USE_LEGACY_TYPE_COMPARISON_OPTION); } @Override diff --git a/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/GlideExtensionValidator.java b/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/GlideExtensionValidator.java index 6d4652b760..fa73874dec 100644 --- a/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/GlideExtensionValidator.java +++ b/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/GlideExtensionValidator.java @@ -7,8 +7,8 @@ import com.google.common.base.Function; import com.google.common.collect.FluentIterable; import com.squareup.javapoet.ClassName; -import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Set; import javax.annotation.processing.ProcessingEnvironment; import javax.lang.model.element.AnnotationMirror; @@ -20,6 +20,8 @@ import javax.lang.model.element.VariableElement; import javax.lang.model.type.DeclaredType; import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; import javax.tools.Diagnostic.Kind; /** @@ -33,11 +35,15 @@ final class GlideExtensionValidator { private final ProcessingEnvironment processingEnvironment; private final ProcessorUtil processorUtil; + private final boolean useLegacyTypeComparison; GlideExtensionValidator( - ProcessingEnvironment processingEnvironment, ProcessorUtil processorUtil) { + ProcessingEnvironment processingEnvironment, + ProcessorUtil processorUtil, + boolean useLegacyTypeComparison) { this.processingEnvironment = processingEnvironment; this.processorUtil = processorUtil; + this.useLegacyTypeComparison = useLegacyTypeComparison; } void validateExtension(TypeElement typeElement) { @@ -109,7 +115,7 @@ private void validateGlideOptionAnnotations(ExecutableElement executableElement) validateAnnotatedNonNull(executableElement); } - private static void validateGlideOptionParameters(ExecutableElement executableElement) { + private void validateGlideOptionParameters(ExecutableElement executableElement) { if (executableElement.getParameters().isEmpty()) { throw new IllegalArgumentException( "@GlideOption methods must take a " @@ -130,8 +136,15 @@ private static void validateGlideOptionParameters(ExecutableElement executableEl } } - private static boolean isBaseRequestOptions(TypeMirror typeMirror) { - return typeMirror.toString().equals("com.bumptech.glide.request.BaseRequestOptions"); + private boolean isBaseRequestOptions(TypeMirror typeMirror) { + if (useLegacyTypeComparison) { + return typeMirror.toString().equals("com.bumptech.glide.request.BaseRequestOptions"); + } + return typeMirror instanceof DeclaredType declaredType + && declaredType.asElement() instanceof TypeElement typeElement + && typeElement + .getQualifiedName() + .contentEquals("com.bumptech.glide.request.BaseRequestOptions"); } private void validateGlideOptionOverride(ExecutableElement element) { @@ -159,7 +172,8 @@ private boolean isMethodInBaseRequestOptions(ExecutableElement toFind) { processingEnvironment .getElementUtils() .getTypeElement(RequestOptionsGenerator.BASE_REQUEST_OPTIONS_QUALIFIED_NAME); - List toFindParameterNames = getComparableParameterNames(toFind, true /*skipFirst*/); + List toFindParameterTypes = + getComparableParameterTypes(toFind, /* skipFirst= */ true); String toFindSimpleName = toFind.getSimpleName().toString(); for (Element element : requestOptionsType.getEnclosedElements()) { if (element.getKind() != ElementKind.METHOD) { @@ -167,27 +181,43 @@ private boolean isMethodInBaseRequestOptions(ExecutableElement toFind) { } ExecutableElement inBase = (ExecutableElement) element; if (toFindSimpleName.equals(inBase.getSimpleName().toString())) { - List parameterNamesInBase = - getComparableParameterNames(inBase, false /*skipFirst*/); - if (parameterNamesInBase.equals(toFindParameterNames)) { - return true; + List parameterTypesInBase = + getComparableParameterTypes(inBase, /* skipFirst= */ false); + if (useLegacyTypeComparison) { + List stringsInBase = parameterTypesInBase.stream().map(Object::toString).toList(); + List stringsToFind = toFindParameterTypes.stream().map(Object::toString).toList(); + if (stringsInBase.equals(stringsToFind)) { + return true; + } + } else { + if (isSameTypes( + processingEnvironment.getTypeUtils(), parameterTypesInBase, toFindParameterTypes)) { + return true; + } } } } return false; } - private static List getComparableParameterNames( - ExecutableElement element, boolean skipFirst) { - List parameters = element.getParameters(); - if (skipFirst) { - parameters = parameters.subList(1, parameters.size()); + private boolean isSameTypes(Types types, List a, List b) { + if (a.size() != b.size()) { + return false; } - List result = new ArrayList<>(parameters.size()); - for (VariableElement parameter : parameters) { - result.add(parameter.asType().toString()); + for (int i = 0; i < a.size(); i++) { + if (!types.isSameType(a.get(i), b.get(i))) { + return false; + } } - return result; + return true; + } + + private static List getComparableParameterTypes( + ExecutableElement element, boolean skipFirst) { + return element.getParameters().stream() + .skip(skipFirst ? 1 : 0) + .map(VariableElement::asType) + .toList(); } private void validateGlideType(ExecutableElement executableElement) { @@ -217,24 +247,48 @@ private String getGlideTypeValue(ExecutableElement executableElement) { } private boolean typeMatchesExpected(TypeMirror returnType, ExecutableElement executableElement) { - if (!(returnType instanceof DeclaredType)) { + if (!(returnType instanceof DeclaredType declaredType)) { + return false; + } + if (useLegacyTypeComparison) { + List typeArguments = declaredType.getTypeArguments(); + if (typeArguments.size() != 1) { + return false; + } + TypeMirror argument = typeArguments.get(0); + String expected = getGlideTypeValue(executableElement); + return argument.toString().equals(expected); + } + Elements elements = processingEnvironment.getElementUtils(); + Types types = processingEnvironment.getTypeUtils(); + String glideTypeValue = getGlideTypeValue(executableElement); + TypeElement glideTypeElement = elements.getTypeElement(glideTypeValue); + if (glideTypeElement == null) { return false; } - List typeArguments = ((DeclaredType) returnType).getTypeArguments(); - if (typeArguments.size() != 1) { + TypeElement requestBuilderElement = + elements.getTypeElement("com.bumptech.glide.RequestBuilder"); + if (requestBuilderElement == null) { return false; } - TypeMirror argument = typeArguments.get(0); - String expected = getGlideTypeValue(executableElement); - return argument.toString().equals(expected); + TypeMirror expectedType = + types.getDeclaredType(requestBuilderElement, glideTypeElement.asType()); + return types.isSameType(returnType, expectedType); } private boolean isRequestBuilder(TypeMirror typeMirror) { - TypeMirror toCompare = processingEnvironment.getTypeUtils().erasure(typeMirror); - return toCompare.toString().equals("com.bumptech.glide.RequestBuilder"); + if (useLegacyTypeComparison) { + TypeMirror toCompare = processingEnvironment.getTypeUtils().erasure(typeMirror); + return toCompare.toString().equals("com.bumptech.glide.RequestBuilder"); + } + Types types = processingEnvironment.getTypeUtils(); + Elements elements = processingEnvironment.getElementUtils(); + TypeMirror toCompare = types.erasure(typeMirror); + return Objects.equals( + types.asElement(toCompare), elements.getTypeElement("com.bumptech.glide.RequestBuilder")); } - private static void validateGlideTypeParameters(ExecutableElement executableElement) { + private void validateGlideTypeParameters(ExecutableElement executableElement) { if (executableElement.getParameters().size() != 1) { throw new IllegalArgumentException( "@GlideType methods must take a" @@ -244,7 +298,9 @@ private static void validateGlideTypeParameters(ExecutableElement executableElem VariableElement first = executableElement.getParameters().get(0); TypeMirror argumentType = first.asType(); - if (!argumentType.toString().startsWith("com.bumptech.glide.RequestBuilder")) { + if (useLegacyTypeComparison + ? !argumentType.toString().startsWith("com.bumptech.glide.RequestBuilder") + : !isRequestBuilder(argumentType)) { throw new IllegalArgumentException( "@GlideType methods must take a" + " RequestBuilder object as their first and only parameter, but given: " diff --git a/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/IndexerGenerator.java b/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/IndexerGenerator.java index 8a05c9151f..387873812d 100644 --- a/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/IndexerGenerator.java +++ b/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/IndexerGenerator.java @@ -7,6 +7,7 @@ import com.squareup.javapoet.TypeSpec; import java.lang.annotation.Annotation; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.UUID; import javax.lang.model.element.Modifier; @@ -83,16 +84,20 @@ TypeSpec generate(List types) { private TypeSpec generate( List libraryModules, Class annotation) { + // Sort modules by qualified name to ensure deterministic ordering + List sortedModules = new ArrayList<>(libraryModules); + sortedModules.sort(Comparator.comparing(a -> a.getQualifiedName().toString())); + AnnotationSpec.Builder annotationBuilder = AnnotationSpec.builder(Index.class); String value = getAnnotationValue(annotation); - for (TypeElement childModule : libraryModules) { + for (TypeElement childModule : sortedModules) { annotationBuilder.addMember(value, "$S", ClassName.get(childModule).toString()); } StringBuilder indexerNameBuilder = new StringBuilder(INDEXER_NAME_PREFIX + annotation.getSimpleName() + "_"); - for (TypeElement element : libraryModules) { + for (TypeElement element : sortedModules) { indexerNameBuilder.append(element.getQualifiedName().toString().replace(".", "_")); indexerNameBuilder.append("_"); } diff --git a/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/ProcessorUtil.java b/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/ProcessorUtil.java index 65a01ab567..6ba171c32a 100644 --- a/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/ProcessorUtil.java +++ b/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/ProcessorUtil.java @@ -10,6 +10,7 @@ import com.google.common.collect.FluentIterable; import com.google.common.collect.ImmutableBiMap; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; import com.squareup.javapoet.AnnotationSpec; import com.squareup.javapoet.ClassName; @@ -20,18 +21,13 @@ import com.squareup.javapoet.TypeName; import com.squareup.javapoet.TypeSpec; import com.squareup.javapoet.TypeVariableName; -import com.sun.tools.javac.code.Attribute; -import com.sun.tools.javac.code.Type.ClassType; import java.lang.annotation.Annotation; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; -import java.util.Map; +import java.util.Locale; import java.util.Set; import javax.annotation.Nullable; import javax.annotation.processing.ProcessingEnvironment; @@ -46,6 +42,8 @@ import javax.lang.model.element.TypeElement; import javax.lang.model.element.TypeParameterElement; import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; import javax.lang.model.type.TypeVariable; import javax.lang.model.util.ElementFilter; @@ -55,8 +53,6 @@ /** Utilities for writing classes and logging. */ final class ProcessorUtil { - // TODO: Remove this once we convert Glide's internal classes to AndroidX. - private static final boolean REQUIRE_SUPPORT_ANNOTATIONS = false; private static final String GLIDE_MODULE_PACKAGE_NAME = "com.bumptech.glide.module"; private static final String APP_GLIDE_MODULE_SIMPLE_NAME = "AppGlideModule"; private static final String LIBRARY_GLIDE_MODULE_SIMPLE_NAME = "LibraryGlideModule"; @@ -410,7 +406,7 @@ private static String computeParameterName(VariableElement parameter, TypeName t } } if (allCaps) { - name = rawClassName.toLowerCase(); + name = rawClassName.toLowerCase(Locale.ROOT); } else { int indexOfLastWordStart = 0; char[] chars = rawClassName.toCharArray(); @@ -433,7 +429,7 @@ private static String computeParameterName(VariableElement parameter, TypeName t private static String getSmartPrimitiveParameterName(VariableElement parameter) { for (AnnotationMirror annotation : parameter.getAnnotationMirrors()) { - String annotationName = annotation.getAnnotationType().toString().toUpperCase(); + String annotationName = annotation.getAnnotationType().toString().toUpperCase(Locale.ROOT); if (annotationName.endsWith("RES")) { // Catch annotations like StringRes return "id"; @@ -503,9 +499,6 @@ static List nonNulls() { } private ClassName findAnnotationClassName(ClassName androidxName, ClassName supportName) { - if (REQUIRE_SUPPORT_ANNOTATIONS) { - return supportName; - } Elements elements = processingEnv.getElementUtils(); TypeElement visibleForTestingTypeElement = elements.getTypeElement(androidxName.reflectionName()); @@ -544,7 +537,7 @@ List findStaticMethods(TypeElement clazz) { .toList(); } - Set findClassValuesFromAnnotationOnClassAsNames( + ImmutableSet findClassValuesFromAnnotationOnClassAsNames( Element clazz, Class annotationClass) { String annotationClassName = annotationClass.getName(); AnnotationValue excludedModuleAnnotationValue = null; @@ -554,17 +547,13 @@ Set findClassValuesFromAnnotationOnClassAsNames( if (!annotationClassName.equals(annotationMirror.getAnnotationType().toString())) { continue; } - Set> values = - annotationMirror.getElementValues().entrySet(); - // Excludes has only one value. If we ever change that, we'd need to iterate over all - // values in the entry set and compare the keys to whatever our Annotation's attribute is - // (usually value). - if (values.size() != 1) { - throw new IllegalArgumentException("Expected single value, but found: " + values); + + var entries = annotationMirror.getElementValues().entrySet(); + if (entries.size() != 1) { + throw new IllegalArgumentException("Expected single value, but found: " + entries); } - excludedModuleAnnotationValue = values.iterator().next().getValue(); - if (excludedModuleAnnotationValue == null - || excludedModuleAnnotationValue instanceof Attribute.UnresolvedClass) { + excludedModuleAnnotationValue = entries.iterator().next().getValue(); + if (excludedModuleAnnotationValue == null) { throw new IllegalArgumentException( "Failed to find value for: " + annotationClass @@ -572,49 +561,34 @@ Set findClassValuesFromAnnotationOnClassAsNames( + clazz.getAnnotationMirrors()); } } + if (excludedModuleAnnotationValue == null) { - return Collections.emptySet(); + return ImmutableSet.of(); } + Object value = excludedModuleAnnotationValue.getValue(); if (value instanceof List) { - List values = (List) value; - Set result = new HashSet<>(values.size()); - for (Object current : values) { - result.add(getExcludedModuleClassFromAnnotationAttribute(clazz, current)); + LinkedHashSet out = new LinkedHashSet<>(); + for (Object o : (List) value) { + AnnotationValue av = (AnnotationValue) o; + out.add(qualifiedNameFromTypeMirror((TypeMirror) av.getValue())); } - return result; + return ImmutableSet.copyOf(out); } else { - ClassType classType = (ClassType) value; - return Collections.singleton(classType.toString()); + return ImmutableSet.of(qualifiedNameFromTypeMirror((TypeMirror) value)); } } - // We should be able to cast to Attribute.Class rather than use reflection, but there are some - // compilers that seem to break when we do so. See #2673 for an example. - private static String getExcludedModuleClassFromAnnotationAttribute( - Element clazz, Object attribute) { - if (attribute.getClass().getSimpleName().equals("UnresolvedClass")) { - throw new IllegalArgumentException( - "Failed to parse @Excludes for: " - + clazz - + ", one or more excluded Modules could not be found at compile time. Ensure that all" - + "excluded Modules are included in your classpath."); + static String qualifiedNameFromTypeMirror(TypeMirror type) { + if (type.getKind() == TypeKind.ERROR) { + throw new IllegalArgumentException("Unresolved class type in annotation: " + type); } - Method[] methods = attribute.getClass().getDeclaredMethods(); - if (methods == null || methods.length == 0) { - throw new IllegalArgumentException( - "Failed to parse @Excludes for: " + clazz + ", invalid exclude: " + attribute); - } - for (Method method : methods) { - if (method.getName().equals("getValue")) { - try { - return method.invoke(attribute).toString(); - } catch (IllegalAccessException | InvocationTargetException e) { - throw new IllegalArgumentException("Failed to parse @Excludes for: " + clazz, e); - } - } + if (type.getKind() == TypeKind.DECLARED) { + DeclaredType dt = (DeclaredType) type; + TypeElement te = (TypeElement) dt.asElement(); + return te.getQualifiedName().toString(); } - throw new IllegalArgumentException("Failed to parse @Excludes for: " + clazz); + return type.toString(); } private enum MethodType { diff --git a/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/RequestBuilderGenerator.java b/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/RequestBuilderGenerator.java index 374dcc84b7..1eebb92646 100644 --- a/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/RequestBuilderGenerator.java +++ b/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/RequestBuilderGenerator.java @@ -102,6 +102,7 @@ final class RequestBuilderGenerator { * RequestBuilder */ private static final String TRANSCODE_TYPE_NAME = "TranscodeType"; + /** A set of method names to avoid overriding from RequestOptions. */ private static final ImmutableSet EXCLUDED_METHODS_FROM_BASE_REQUEST_OPTIONS = ImmutableSet.of("clone", "apply"); @@ -186,6 +187,7 @@ TypeSpec generate( .addMethods(requestOptionsExtensionMethods) .build(); } + /** * Generates methods with equivalent names and arguments to methods annotated with {@link * GlideOption} in {@link com.bumptech.glide.annotation.GlideExtension}s that return our generated diff --git a/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/RequestOptionsGenerator.java b/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/RequestOptionsGenerator.java index 5e7e027b31..fc1d70ed34 100644 --- a/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/RequestOptionsGenerator.java +++ b/annotation/compiler/src/main/java/com/bumptech/glide/annotation/compiler/RequestOptionsGenerator.java @@ -316,6 +316,7 @@ private MethodAndStaticVar generateStaticMethodEquivalentForRequestOptionsStatic return new MethodAndStaticVar(methodSpecBuilder.build(), requiredStaticField); } + @SuppressWarnings("checkstyle:UnnecessaryParentheses") // Readability private static boolean memoizeStaticMethodFromArguments(ExecutableElement staticMethod) { return staticMethod.getParameters().isEmpty() || (staticMethod.getParameters().size() == 1 diff --git a/annotation/compiler/test/build.gradle b/annotation/compiler/test/build.gradle index 8e18637060..84120cce33 100644 --- a/annotation/compiler/test/build.gradle +++ b/annotation/compiler/test/build.gradle @@ -1,5 +1,3 @@ -import org.gradle.internal.jvm.Jvm - apply plugin: 'com.android.library' android { @@ -25,21 +23,22 @@ android { afterEvaluate { lint.enabled = false - compileDebugJavaWithJavac.enabled = false + compileReleaseJavaWithJavac.enabled = false } android { - compileSdkVersion COMPILE_SDK_VERSION as int + namespace 'com.bumptech.glide.annotation.compiler.test' + compileSdk libs.versions.compile.sdk.version.get().toInteger() defaultConfig { - minSdkVersion MIN_SDK_VERSION as int - targetSdkVersion TARGET_SDK_VERSION as int + minSdk libs.versions.min.sdk.version.get() as int + targetSdk libs.versions.target.sdk.version.get() as int versionName VERSION_NAME as String } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } testOptions { @@ -58,9 +57,9 @@ android { dependencies { testImplementation project(':glide') testImplementation project(':annotation:compiler') - testImplementation "junit:junit:${JUNIT_VERSION}" - testImplementation "com.squareup:javapoet:${JAVAPOET_VERSION}" - testImplementation "com.google.code.findbugs:jsr305:${JSR_305_VERSION}" + testImplementation libs.junit + testImplementation libs.javapoet + testImplementation libs.findbugs.jsr305 // Using 0.10 of compile-testing is required for Android Studio to function, but not for the // gradle build. Not yet clear why, but it looks like some kind of version conflict between // javapoet, guava and/or truth. @@ -70,14 +69,13 @@ dependencies { // confusing. exclude group: "com.google.auto.value", module: "auto-value" } - testImplementation "androidx.annotation:annotation:${ANDROID_X_ANNOTATION_VERSION}" - testImplementation "androidx.fragment:fragment:${ANDROID_X_FRAGMENT_VERSION}" - testImplementation "androidx.legacy:legacy-support-v4:${ANDROID_X_VERSION}" - // TODO: this seems excessive, but it works... - testImplementation files(Jvm.current().getJre().homeDir.getAbsolutePath()+'/lib/rt.jar') + testImplementation libs.androidx.annotation + testImplementation libs.androidx.fragment + // TODO: Find some way to include a similar dependency on java 9+ and re-enable these tests in gradle. +// testImplementation files(Jvm.current().getJre().homeDir.getAbsolutePath()+'/lib/rt.jar') testAnnotationProcessor project(':annotation:compiler') - testAnnotationProcessor "com.google.auto.service:auto-service:${AUTO_SERVICE_VERSION}" + testAnnotationProcessor libs.autoservice } task regenerateTestResources { @@ -95,6 +93,5 @@ task regenerateTestResources { } afterEvaluate { - regenerateTestResources.finalizedBy(testDebugUnitTest) + regenerateTestResources.finalizedBy(testReleaseUnitTest) } - diff --git a/annotation/compiler/test/src/main/AndroidManifest.xml b/annotation/compiler/test/src/main/AndroidManifest.xml deleted file mode 100644 index a41245187e..0000000000 --- a/annotation/compiler/test/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/annotation/compiler/test/src/test/java/com/bumptech/glide/annotation/compiler/test/Util.java b/annotation/compiler/test/src/test/java/com/bumptech/glide/annotation/compiler/test/Util.java index 85d51e38c2..49567a2932 100644 --- a/annotation/compiler/test/src/test/java/com/bumptech/glide/annotation/compiler/test/Util.java +++ b/annotation/compiler/test/src/test/java/com/bumptech/glide/annotation/compiler/test/Util.java @@ -12,6 +12,7 @@ public final class Util { private static final String ANNOTATION_PACKAGE_NAME = "com.bumptech.glide.annotation.compiler"; private static final String DEFAULT_APP_DIR_NAME = "EmptyAppGlideModuleTest"; private static final String DEFAULT_LIBRARY_DIR_NAME = "EmptyLibraryGlideModuleTest"; + /** * Hardcoded file separator to workaround {@code JavaFileObjects.forResource(...)} defaulting to * the unix one. diff --git a/annotation/compiler/test/src/test/resources/AppGlideModuleWithExcludesTest/GeneratedAppGlideModuleImpl.java b/annotation/compiler/test/src/test/resources/AppGlideModuleWithExcludesTest/GeneratedAppGlideModuleImpl.java index 65601ab29a..37c370dd32 100644 --- a/annotation/compiler/test/src/test/resources/AppGlideModuleWithExcludesTest/GeneratedAppGlideModuleImpl.java +++ b/annotation/compiler/test/src/test/resources/AppGlideModuleWithExcludesTest/GeneratedAppGlideModuleImpl.java @@ -1,8 +1,8 @@ package com.bumptech.glide; import android.content.Context; -import androidx.annotation.NonNull; import android.util.Log; +import androidx.annotation.NonNull; import com.bumptech.glide.test.AppModuleWithExcludes; import java.util.HashSet; import java.util.Set; diff --git a/annotation/compiler/test/src/test/resources/AppGlideModuleWithLibraryInPackageTest/GeneratedAppGlideModuleImpl.java b/annotation/compiler/test/src/test/resources/AppGlideModuleWithLibraryInPackageTest/GeneratedAppGlideModuleImpl.java index 02d9b660f9..f2ad77e022 100644 --- a/annotation/compiler/test/src/test/resources/AppGlideModuleWithLibraryInPackageTest/GeneratedAppGlideModuleImpl.java +++ b/annotation/compiler/test/src/test/resources/AppGlideModuleWithLibraryInPackageTest/GeneratedAppGlideModuleImpl.java @@ -1,8 +1,8 @@ package com.bumptech.glide; import android.content.Context; -import androidx.annotation.NonNull; import android.util.Log; +import androidx.annotation.NonNull; import com.bumptech.glide.test.AppModuleWithLibraryInPackage; import java.util.HashSet; import java.util.Set; diff --git a/annotation/compiler/test/src/test/resources/AppGlideModuleWithMultipleExcludesTest/GeneratedAppGlideModuleImpl.java b/annotation/compiler/test/src/test/resources/AppGlideModuleWithMultipleExcludesTest/GeneratedAppGlideModuleImpl.java index 6b285af414..c0f30f3838 100644 --- a/annotation/compiler/test/src/test/resources/AppGlideModuleWithMultipleExcludesTest/GeneratedAppGlideModuleImpl.java +++ b/annotation/compiler/test/src/test/resources/AppGlideModuleWithMultipleExcludesTest/GeneratedAppGlideModuleImpl.java @@ -1,8 +1,8 @@ package com.bumptech.glide; import android.content.Context; -import androidx.annotation.NonNull; import android.util.Log; +import androidx.annotation.NonNull; import com.bumptech.glide.test.AppModuleWithMultipleExcludes; import java.util.HashSet; import java.util.Set; diff --git a/annotation/compiler/test/src/test/resources/EmptyAppAndLibraryGlideModulesTest/GeneratedAppGlideModuleImpl.java b/annotation/compiler/test/src/test/resources/EmptyAppAndLibraryGlideModulesTest/GeneratedAppGlideModuleImpl.java index 7180b6f625..d8e26dbdf7 100644 --- a/annotation/compiler/test/src/test/resources/EmptyAppAndLibraryGlideModulesTest/GeneratedAppGlideModuleImpl.java +++ b/annotation/compiler/test/src/test/resources/EmptyAppAndLibraryGlideModulesTest/GeneratedAppGlideModuleImpl.java @@ -1,8 +1,8 @@ package com.bumptech.glide; import android.content.Context; -import androidx.annotation.NonNull; import android.util.Log; +import androidx.annotation.NonNull; import com.bumptech.glide.test.EmptyAppModule; import com.bumptech.glide.test.EmptyLibraryModule; import java.util.Collections; diff --git a/annotation/compiler/test/src/test/resources/EmptyAppGlideModuleTest/GeneratedAppGlideModuleImpl.java b/annotation/compiler/test/src/test/resources/EmptyAppGlideModuleTest/GeneratedAppGlideModuleImpl.java index 8ed710b2bc..c5e014dded 100644 --- a/annotation/compiler/test/src/test/resources/EmptyAppGlideModuleTest/GeneratedAppGlideModuleImpl.java +++ b/annotation/compiler/test/src/test/resources/EmptyAppGlideModuleTest/GeneratedAppGlideModuleImpl.java @@ -1,8 +1,8 @@ package com.bumptech.glide; import android.content.Context; -import androidx.annotation.NonNull; import android.util.Log; +import androidx.annotation.NonNull; import com.bumptech.glide.test.EmptyAppModule; import java.util.Collections; import java.util.Set; diff --git a/annotation/compiler/test/src/test/resources/EmptyAppGlideModuleTest/GlideApp.java b/annotation/compiler/test/src/test/resources/EmptyAppGlideModuleTest/GlideApp.java index 2b18540210..01cb075b2f 100644 --- a/annotation/compiler/test/src/test/resources/EmptyAppGlideModuleTest/GlideApp.java +++ b/annotation/compiler/test/src/test/resources/EmptyAppGlideModuleTest/GlideApp.java @@ -3,12 +3,12 @@ import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.bumptech.glide.Glide; import com.bumptech.glide.GlideBuilder; import java.io.File; @@ -98,6 +98,7 @@ public static GlideRequests with(@NonNull Context context) { /** * @see Glide#with(Activity) */ + @Deprecated @NonNull public static GlideRequests with(@NonNull Activity activity) { return (GlideRequests) Glide.with(activity); diff --git a/annotation/compiler/test/src/test/resources/EmptyAppGlideModuleTest/GlideRequest.java b/annotation/compiler/test/src/test/resources/EmptyAppGlideModuleTest/GlideRequest.java index 5227b83d8c..01db2c8241 100644 --- a/annotation/compiler/test/src/test/resources/EmptyAppGlideModuleTest/GlideRequest.java +++ b/annotation/compiler/test/src/test/resources/EmptyAppGlideModuleTest/GlideRequest.java @@ -399,7 +399,8 @@ public GlideRequest transform(@NonNull Transformation... "unchecked", "varargs" }) - public GlideRequest transforms(@NonNull Transformation... transformations) { + public GlideRequest transforms( + @NonNull Transformation... transformations) { return (GlideRequest) super.transforms(transformations); } @@ -408,7 +409,8 @@ public GlideRequest transforms(@NonNull Transformation... */ @NonNull @CheckResult - public GlideRequest optionalTransform(@NonNull Transformation transformation) { + public GlideRequest optionalTransform( + @NonNull Transformation transformation) { return (GlideRequest) super.optionalTransform(transformation); } @@ -476,7 +478,8 @@ public GlideRequest apply(@NonNull BaseRequestOptions options) @Override @NonNull @CheckResult - public GlideRequest transition(@NonNull TransitionOptions options) { + public GlideRequest transition( + @NonNull TransitionOptions options) { return (GlideRequest) super.transition(options); } @@ -490,7 +493,8 @@ public GlideRequest listener(@Nullable RequestListener addListener(@Nullable RequestListener listener) { + public GlideRequest addListener( + @Nullable RequestListener listener) { return (GlideRequest) super.addListener(listener); } @@ -519,7 +523,8 @@ public GlideRequest thumbnail(@Nullable RequestBuilder thumbnail(@Nullable RequestBuilder... builders) { + public final GlideRequest thumbnail( + @Nullable RequestBuilder... builders) { return (GlideRequest) super.thumbnail(builders); } @@ -531,6 +536,7 @@ public GlideRequest thumbnail(@Nullable List thumbnail(float sizeMultiplier) { diff --git a/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/MemoizeStaticMethod/GlideRequest.java b/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/MemoizeStaticMethod/GlideRequest.java index 9477d1a6b3..b2ec0d3b89 100644 --- a/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/MemoizeStaticMethod/GlideRequest.java +++ b/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/MemoizeStaticMethod/GlideRequest.java @@ -399,7 +399,8 @@ public GlideRequest transform(@NonNull Transformation... "unchecked", "varargs" }) - public GlideRequest transforms(@NonNull Transformation... transformations) { + public GlideRequest transforms( + @NonNull Transformation... transformations) { return (GlideRequest) super.transforms(transformations); } @@ -408,7 +409,8 @@ public GlideRequest transforms(@NonNull Transformation... */ @NonNull @CheckResult - public GlideRequest optionalTransform(@NonNull Transformation transformation) { + public GlideRequest optionalTransform( + @NonNull Transformation transformation) { return (GlideRequest) super.optionalTransform(transformation); } @@ -476,7 +478,8 @@ public GlideRequest apply(@NonNull BaseRequestOptions options) @Override @NonNull @CheckResult - public GlideRequest transition(@NonNull TransitionOptions options) { + public GlideRequest transition( + @NonNull TransitionOptions options) { return (GlideRequest) super.transition(options); } @@ -490,7 +493,8 @@ public GlideRequest listener(@Nullable RequestListener addListener(@Nullable RequestListener listener) { + public GlideRequest addListener( + @Nullable RequestListener listener) { return (GlideRequest) super.addListener(listener); } @@ -519,7 +523,8 @@ public GlideRequest thumbnail(@Nullable RequestBuilder thumbnail(@Nullable RequestBuilder... builders) { + public final GlideRequest thumbnail( + @Nullable RequestBuilder... builders) { return (GlideRequest) super.thumbnail(builders); } @@ -531,6 +536,7 @@ public GlideRequest thumbnail(@Nullable List thumbnail(float sizeMultiplier) { diff --git a/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/OverrideExtend/GlideRequest.java b/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/OverrideExtend/GlideRequest.java index 82e614850a..a3ec323db2 100644 --- a/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/OverrideExtend/GlideRequest.java +++ b/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/OverrideExtend/GlideRequest.java @@ -390,7 +390,8 @@ public GlideRequest transform(@NonNull Transformation... "unchecked", "varargs" }) - public GlideRequest transforms(@NonNull Transformation... transformations) { + public GlideRequest transforms( + @NonNull Transformation... transformations) { return (GlideRequest) super.transforms(transformations); } @@ -399,7 +400,8 @@ public GlideRequest transforms(@NonNull Transformation... */ @NonNull @CheckResult - public GlideRequest optionalTransform(@NonNull Transformation transformation) { + public GlideRequest optionalTransform( + @NonNull Transformation transformation) { return (GlideRequest) super.optionalTransform(transformation); } @@ -467,7 +469,8 @@ public GlideRequest apply(@NonNull BaseRequestOptions options) @Override @NonNull @CheckResult - public GlideRequest transition(@NonNull TransitionOptions options) { + public GlideRequest transition( + @NonNull TransitionOptions options) { return (GlideRequest) super.transition(options); } @@ -481,7 +484,8 @@ public GlideRequest listener(@Nullable RequestListener addListener(@Nullable RequestListener listener) { + public GlideRequest addListener( + @Nullable RequestListener listener) { return (GlideRequest) super.addListener(listener); } @@ -510,7 +514,8 @@ public GlideRequest thumbnail(@Nullable RequestBuilder thumbnail(@Nullable RequestBuilder... builders) { + public final GlideRequest thumbnail( + @Nullable RequestBuilder... builders) { return (GlideRequest) super.thumbnail(builders); } @@ -522,6 +527,7 @@ public GlideRequest thumbnail(@Nullable List thumbnail(float sizeMultiplier) { diff --git a/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/OverrideExtendMultipleArguments/GlideRequest.java b/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/OverrideExtendMultipleArguments/GlideRequest.java index 7659a49885..686599f6ba 100644 --- a/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/OverrideExtendMultipleArguments/GlideRequest.java +++ b/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/OverrideExtendMultipleArguments/GlideRequest.java @@ -390,7 +390,8 @@ public GlideRequest transform(@NonNull Transformation... "unchecked", "varargs" }) - public GlideRequest transforms(@NonNull Transformation... transformations) { + public GlideRequest transforms( + @NonNull Transformation... transformations) { return (GlideRequest) super.transforms(transformations); } @@ -399,7 +400,8 @@ public GlideRequest transforms(@NonNull Transformation... */ @NonNull @CheckResult - public GlideRequest optionalTransform(@NonNull Transformation transformation) { + public GlideRequest optionalTransform( + @NonNull Transformation transformation) { return (GlideRequest) super.optionalTransform(transformation); } @@ -467,7 +469,8 @@ public GlideRequest apply(@NonNull BaseRequestOptions options) @Override @NonNull @CheckResult - public GlideRequest transition(@NonNull TransitionOptions options) { + public GlideRequest transition( + @NonNull TransitionOptions options) { return (GlideRequest) super.transition(options); } @@ -481,7 +484,8 @@ public GlideRequest listener(@Nullable RequestListener addListener(@Nullable RequestListener listener) { + public GlideRequest addListener( + @Nullable RequestListener listener) { return (GlideRequest) super.addListener(listener); } @@ -510,7 +514,8 @@ public GlideRequest thumbnail(@Nullable RequestBuilder thumbnail(@Nullable RequestBuilder... builders) { + public final GlideRequest thumbnail( + @Nullable RequestBuilder... builders) { return (GlideRequest) super.thumbnail(builders); } @@ -522,6 +527,7 @@ public GlideRequest thumbnail(@Nullable List thumbnail(float sizeMultiplier) { diff --git a/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/OverrideReplace/GlideRequest.java b/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/OverrideReplace/GlideRequest.java index ad0f3581eb..cda7712314 100644 --- a/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/OverrideReplace/GlideRequest.java +++ b/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/OverrideReplace/GlideRequest.java @@ -390,7 +390,8 @@ public GlideRequest transform(@NonNull Transformation... "unchecked", "varargs" }) - public GlideRequest transforms(@NonNull Transformation... transformations) { + public GlideRequest transforms( + @NonNull Transformation... transformations) { return (GlideRequest) super.transforms(transformations); } @@ -399,7 +400,8 @@ public GlideRequest transforms(@NonNull Transformation... */ @NonNull @CheckResult - public GlideRequest optionalTransform(@NonNull Transformation transformation) { + public GlideRequest optionalTransform( + @NonNull Transformation transformation) { return (GlideRequest) super.optionalTransform(transformation); } @@ -467,7 +469,8 @@ public GlideRequest apply(@NonNull BaseRequestOptions options) @Override @NonNull @CheckResult - public GlideRequest transition(@NonNull TransitionOptions options) { + public GlideRequest transition( + @NonNull TransitionOptions options) { return (GlideRequest) super.transition(options); } @@ -481,7 +484,8 @@ public GlideRequest listener(@Nullable RequestListener addListener(@Nullable RequestListener listener) { + public GlideRequest addListener( + @Nullable RequestListener listener) { return (GlideRequest) super.addListener(listener); } @@ -510,7 +514,8 @@ public GlideRequest thumbnail(@Nullable RequestBuilder thumbnail(@Nullable RequestBuilder... builders) { + public final GlideRequest thumbnail( + @Nullable RequestBuilder... builders) { return (GlideRequest) super.thumbnail(builders); } @@ -522,6 +527,7 @@ public GlideRequest thumbnail(@Nullable List thumbnail(float sizeMultiplier) { diff --git a/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/SkipStaticMethod/GlideRequest.java b/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/SkipStaticMethod/GlideRequest.java index 9477d1a6b3..b2ec0d3b89 100644 --- a/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/SkipStaticMethod/GlideRequest.java +++ b/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/SkipStaticMethod/GlideRequest.java @@ -399,7 +399,8 @@ public GlideRequest transform(@NonNull Transformation... "unchecked", "varargs" }) - public GlideRequest transforms(@NonNull Transformation... transformations) { + public GlideRequest transforms( + @NonNull Transformation... transformations) { return (GlideRequest) super.transforms(transformations); } @@ -408,7 +409,8 @@ public GlideRequest transforms(@NonNull Transformation... */ @NonNull @CheckResult - public GlideRequest optionalTransform(@NonNull Transformation transformation) { + public GlideRequest optionalTransform( + @NonNull Transformation transformation) { return (GlideRequest) super.optionalTransform(transformation); } @@ -476,7 +478,8 @@ public GlideRequest apply(@NonNull BaseRequestOptions options) @Override @NonNull @CheckResult - public GlideRequest transition(@NonNull TransitionOptions options) { + public GlideRequest transition( + @NonNull TransitionOptions options) { return (GlideRequest) super.transition(options); } @@ -490,7 +493,8 @@ public GlideRequest listener(@Nullable RequestListener addListener(@Nullable RequestListener listener) { + public GlideRequest addListener( + @Nullable RequestListener listener) { return (GlideRequest) super.addListener(listener); } @@ -519,7 +523,8 @@ public GlideRequest thumbnail(@Nullable RequestBuilder thumbnail(@Nullable RequestBuilder... builders) { + public final GlideRequest thumbnail( + @Nullable RequestBuilder... builders) { return (GlideRequest) super.thumbnail(builders); } @@ -531,6 +536,7 @@ public GlideRequest thumbnail(@Nullable List thumbnail(float sizeMultiplier) { diff --git a/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/StaticMethodName/GlideRequest.java b/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/StaticMethodName/GlideRequest.java index 9477d1a6b3..b2ec0d3b89 100644 --- a/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/StaticMethodName/GlideRequest.java +++ b/annotation/compiler/test/src/test/resources/GlideExtensionOptionsTest/StaticMethodName/GlideRequest.java @@ -399,7 +399,8 @@ public GlideRequest transform(@NonNull Transformation... "unchecked", "varargs" }) - public GlideRequest transforms(@NonNull Transformation... transformations) { + public GlideRequest transforms( + @NonNull Transformation... transformations) { return (GlideRequest) super.transforms(transformations); } @@ -408,7 +409,8 @@ public GlideRequest transforms(@NonNull Transformation... */ @NonNull @CheckResult - public GlideRequest optionalTransform(@NonNull Transformation transformation) { + public GlideRequest optionalTransform( + @NonNull Transformation transformation) { return (GlideRequest) super.optionalTransform(transformation); } @@ -476,7 +478,8 @@ public GlideRequest apply(@NonNull BaseRequestOptions options) @Override @NonNull @CheckResult - public GlideRequest transition(@NonNull TransitionOptions options) { + public GlideRequest transition( + @NonNull TransitionOptions options) { return (GlideRequest) super.transition(options); } @@ -490,7 +493,8 @@ public GlideRequest listener(@Nullable RequestListener addListener(@Nullable RequestListener listener) { + public GlideRequest addListener( + @Nullable RequestListener listener) { return (GlideRequest) super.addListener(listener); } @@ -519,7 +523,8 @@ public GlideRequest thumbnail(@Nullable RequestBuilder thumbnail(@Nullable RequestBuilder... builders) { + public final GlideRequest thumbnail( + @Nullable RequestBuilder... builders) { return (GlideRequest) super.thumbnail(builders); } @@ -531,6 +536,7 @@ public GlideRequest thumbnail(@Nullable List thumbnail(float sizeMultiplier) { diff --git a/annotation/compiler/test/src/test/resources/GlideExtensionWithOptionTest/GlideRequest.java b/annotation/compiler/test/src/test/resources/GlideExtensionWithOptionTest/GlideRequest.java index b0921ad37c..87b432e0d2 100644 --- a/annotation/compiler/test/src/test/resources/GlideExtensionWithOptionTest/GlideRequest.java +++ b/annotation/compiler/test/src/test/resources/GlideExtensionWithOptionTest/GlideRequest.java @@ -399,7 +399,8 @@ public GlideRequest transform(@NonNull Transformation... "unchecked", "varargs" }) - public GlideRequest transforms(@NonNull Transformation... transformations) { + public GlideRequest transforms( + @NonNull Transformation... transformations) { return (GlideRequest) super.transforms(transformations); } @@ -408,7 +409,8 @@ public GlideRequest transforms(@NonNull Transformation... */ @NonNull @CheckResult - public GlideRequest optionalTransform(@NonNull Transformation transformation) { + public GlideRequest optionalTransform( + @NonNull Transformation transformation) { return (GlideRequest) super.optionalTransform(transformation); } @@ -476,7 +478,8 @@ public GlideRequest apply(@NonNull BaseRequestOptions options) @Override @NonNull @CheckResult - public GlideRequest transition(@NonNull TransitionOptions options) { + public GlideRequest transition( + @NonNull TransitionOptions options) { return (GlideRequest) super.transition(options); } @@ -490,7 +493,8 @@ public GlideRequest listener(@Nullable RequestListener addListener(@Nullable RequestListener listener) { + public GlideRequest addListener( + @Nullable RequestListener listener) { return (GlideRequest) super.addListener(listener); } @@ -519,7 +523,8 @@ public GlideRequest thumbnail(@Nullable RequestBuilder thumbnail(@Nullable RequestBuilder... builders) { + public final GlideRequest thumbnail( + @Nullable RequestBuilder... builders) { return (GlideRequest) super.thumbnail(builders); } @@ -531,6 +536,7 @@ public GlideRequest thumbnail(@Nullable List thumbnail(float sizeMultiplier) { diff --git a/annotation/ksp/build.gradle.kts b/annotation/ksp/build.gradle.kts new file mode 100644 index 0000000000..1ecc1513ff --- /dev/null +++ b/annotation/ksp/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + id("com.google.devtools.ksp") +} + +kotlin { jvmToolchain { languageVersion.set(JavaLanguageVersion.of(11)) } } + +dependencies { + implementation(libs.kotlinpoet) + implementation(project(":annotation")) + implementation(libs.ksp.api) + implementation(libs.autoservice.annotations) + + ksp(libs.ksp.autoservice) +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") diff --git a/annotation/ksp/gradle.properties b/annotation/ksp/gradle.properties new file mode 100644 index 0000000000..d5a6e443f8 --- /dev/null +++ b/annotation/ksp/gradle.properties @@ -0,0 +1,6 @@ +kotlin.code.style=official + +POM_NAME=Glide KSP Annotation Processor +POM_ARTIFACT_ID=ksp +POM_PACKAGING=jar +POM_DESCRIPTION=Glide's KSP based annotation processor. Should be included in all Kotlin applications and libraries that use Glide's modules for configuration and do not require the more advanced features of the Java based compiler. diff --git a/annotation/ksp/integrationtest/build.gradle.kts b/annotation/ksp/integrationtest/build.gradle.kts new file mode 100644 index 0000000000..f52bb2a220 --- /dev/null +++ b/annotation/ksp/integrationtest/build.gradle.kts @@ -0,0 +1,51 @@ +/** + * This package verifies that our ksp processor is able to successfully import + * and include LibraryGlideModules compiled in other modules. ksp:test is a more + * comprehensive set of unit tests for other scenarios for library tests. + * + *

Technically we could include these integration tests in ksp:test. However + * doing so would cause the dependent library to pollute every individual test + * because it's pulled in from the classpath. Using a separate module allows us + * to keep unit tests that are not concerned with dependent library modules + * separate. + */ + +plugins { + id("com.android.library") +} + +android { + namespace = "com.bumptech.glide.annotation.ksp.integrationtest" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +kotlin { + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(11)) + } +} + +dependencies { + implementation(libs.junit) + testImplementation(project(":annotation:ksp:test")) + testImplementation(project(":annotation:ksp")) + testImplementation(project(":annotation")) + testImplementation(project(":glide")) + testImplementation(project(":integration:okhttp3")) + testImplementation(libs.ksp.compiletesting) + testImplementation(libs.truth) + testImplementation(libs.kotlin.test) + testImplementation(project(":annotation:ksp:test")) +} + +tasks.withType().configureEach { + enabled = false +} diff --git a/annotation/ksp/integrationtest/src/test/java/com/bumptech/glide/annotation/ksp/integrationtest/IntegrationLibraryGlideModuleTests.kt b/annotation/ksp/integrationtest/src/test/java/com/bumptech/glide/annotation/ksp/integrationtest/IntegrationLibraryGlideModuleTests.kt new file mode 100644 index 0000000000..41e40449a7 --- /dev/null +++ b/annotation/ksp/integrationtest/src/test/java/com/bumptech/glide/annotation/ksp/integrationtest/IntegrationLibraryGlideModuleTests.kt @@ -0,0 +1,421 @@ +package com.bumptech.glide.annotation.ksp.integrationtest + +import com.bumptech.glide.annotation.ksp.test.CommonSources +import com.bumptech.glide.annotation.ksp.test.JavaSourceFile +import com.bumptech.glide.annotation.ksp.test.KotlinSourceFile +import com.bumptech.glide.annotation.ksp.test.PerSourceTypeTest +import com.bumptech.glide.annotation.ksp.test.SourceType +import com.bumptech.glide.annotation.ksp.test.hasSourceEqualTo +import com.google.common.truth.Truth.assertThat +import com.tschuchort.compiletesting.KotlinCompilation.ExitCode +import org.intellij.lang.annotations.Language +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters + +@OptIn(ExperimentalCompilerApi::class) +@RunWith(Parameterized::class) +class IntegrationLibraryGlideModuleTests(override val sourceType: SourceType) : PerSourceTypeTest { + + companion object { + @Parameters(name = "sourceType = {0}") @JvmStatic fun data() = SourceType.values() + } + + @Test + fun compile_withOnlyAppGlideModule_generatesGeneratedAppGlideModule_thatCallsDependencyLibraryGlideModules() { + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule class AppModule : AppGlideModule() + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule public class AppModule extends AppGlideModule { + public AppModule() {} + } + """, + ) + + compileCurrentSourceType(kotlinAppModule, javaAppModule) { + assertThat(it.exitCode).isEqualTo(ExitCode.OK) + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(appGlideModuleWithOnlyDependencyLibraryModules) + } + } + + @Test + fun compile_withOnlyAppGlideModuleThroughBaseClass_generatesGeneratedAppGlideModule_thatCallsDependencyLibraryGlideModules() { + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + class BaseAppModule : AppGlideModule() + @GlideModule class AppModule : BaseAppModule() + """, + ) + val javaBaseAppModule = + JavaSourceFile( + "BaseAppModule.java", + """ + import com.bumptech.glide.module.AppGlideModule; + + public class BaseAppModule extends AppGlideModule { + public BaseAppModule() {} + } + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + + @GlideModule public class AppModule extends BaseAppModule { + public AppModule() {} + } + """, + ) + + compileCurrentSourceType(kotlinAppModule, javaBaseAppModule, javaAppModule) { + assertThat(it.exitCode).isEqualTo(ExitCode.OK) + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(appGlideModuleWithOnlyDependencyLibraryModules) + } + } + + @Test + fun compile_withValidLibraryGlideModule_andAppGlideModule_generatesGeneratedAppGlideModule_thatCallsAllLibraryAndDependencyAndAppGlideModules() { + val kotlinLibraryModule = + KotlinSourceFile( + "LibraryModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class LibraryModule : LibraryGlideModule() + """, + ) + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule class AppModule : AppGlideModule() + """, + ) + val javaLibraryModule = + JavaSourceFile( + "LibraryModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.LibraryGlideModule; + + @GlideModule public class LibraryModule extends LibraryGlideModule {} + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule public class AppModule extends AppGlideModule { + public AppModule() {} + } + """, + ) + + compileCurrentSourceType( + kotlinAppModule, + kotlinLibraryModule, + javaAppModule, + javaLibraryModule, + ) { + assertThat(it.exitCode).isEqualTo(ExitCode.OK) + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(appGlideModuleWithLibraryModuleAndDependencyLibraryModules) + } + } + + @Test + fun compile_withValidLibraryGlideModule_andAppGlideModule_ThroughBaseClass_generatesGeneratedAppGlideModule_thatCallsAllLibraryAndDependencyAndAppGlideModules() { + val kotlinLibraryModule = + KotlinSourceFile( + "LibraryModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + class BaseLibraryModule : LibraryGlideModule() + @GlideModule class LibraryModule : BaseLibraryModule() + """, + ) + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + class BaseAppModule : AppGlideModule() + @GlideModule class AppModule : BaseAppModule() + """, + ) + val javaBaseLibraryModule = + JavaSourceFile( + "BaseLibraryModule.java", + """ + import com.bumptech.glide.module.LibraryGlideModule; + + public class BaseLibraryModule extends LibraryGlideModule {} + """, + ) + val javaLibraryModule = + JavaSourceFile( + "LibraryModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + + @GlideModule public class LibraryModule extends BaseLibraryModule {} + """, + ) + val javaBaseAppModule = + JavaSourceFile( + "BaseAppModule.java", + """ + import com.bumptech.glide.module.AppGlideModule; + + public class BaseAppModule extends AppGlideModule { + public BaseAppModule() {} + } + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + + @GlideModule public class AppModule extends BaseAppModule { + public AppModule() {} + } + """, + ) + + compileCurrentSourceType( + kotlinAppModule, + kotlinLibraryModule, + javaBaseAppModule, + javaAppModule, + javaBaseLibraryModule, + javaLibraryModule, + ) { + assertThat(it.exitCode).isEqualTo(ExitCode.OK) + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(appGlideModuleWithLibraryModuleAndDependencyLibraryModules) + } + } + + @Test + fun compile_withDependencyModuleInExcludes_generatesGeneratedAppGlideModule_thatDoesNotCallDependencyLibraryGlideModules() { + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.Excludes + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + import com.bumptech.glide.integration.okhttp3.OkHttpLibraryGlideModule + + @Excludes(OkHttpLibraryGlideModule::class) + @GlideModule class AppModule : AppGlideModule() + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.Excludes; + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + import com.bumptech.glide.integration.okhttp3.OkHttpLibraryGlideModule; + + @Excludes(OkHttpLibraryGlideModule.class) + @GlideModule + public class AppModule extends AppGlideModule { + public AppModule() {} + } + """, + ) + + compileCurrentSourceType(kotlinAppModule, javaAppModule) { + assertThat(it.exitCode).isEqualTo(ExitCode.OK) + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(CommonSources.simpleAppGlideModule) + } + } + + @Test + fun compile_withLibraryModuleInExcludes_producesGeneratedAppGlideModuleThatDoesNotCallExcludedLibraryModule() { + val kotlinExcludedLibraryModule = + KotlinSourceFile( + "ExcludedLibraryModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class ExcludedLibraryModule : LibraryGlideModule() + """, + ) + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.Excludes + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule + @Excludes(ExcludedLibraryModule::class) + class AppModule : AppGlideModule() + """, + ) + + val javaExcludedLibraryModule = + JavaSourceFile( + "ExcludedLibraryModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.LibraryGlideModule; + + @GlideModule + public class ExcludedLibraryModule extends LibraryGlideModule {} + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.Excludes; + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule + @Excludes(ExcludedLibraryModule.class) + public class AppModule extends AppGlideModule { + public AppModule() {} + } + """, + ) + compileCurrentSourceType( + kotlinAppModule, + kotlinExcludedLibraryModule, + javaAppModule, + javaExcludedLibraryModule, + ) { + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(appGlideModuleWithOnlyDependencyLibraryModules) + assertThat(it.exitCode).isEqualTo(ExitCode.OK) + } + } +} + +// generated code always includes public and Unit +@Suppress("RedundantVisibilityModifier", "RedundantUnitReturnType") +@Language("kotlin") +const val appGlideModuleWithLibraryModuleAndDependencyLibraryModules = + """ +package com.bumptech.glide + +import AppModule +import LibraryModule +import android.content.Context +import com.bumptech.glide.integration.okhttp3.OkHttpLibraryGlideModule +import kotlin.Boolean +import kotlin.Suppress +import kotlin.Unit + +internal class GeneratedAppGlideModuleImpl( + @Suppress("UNUSED_PARAMETER") + context: Context, +) : GeneratedAppGlideModule() { + private val appGlideModule: AppModule + init { + appGlideModule = AppModule() + } + + public override fun registerComponents( + context: Context, + glide: Glide, + registry: Registry, + ): Unit { + OkHttpLibraryGlideModule().registerComponents(context, glide, registry) + LibraryModule().registerComponents(context, glide, registry) + appGlideModule.registerComponents(context, glide, registry) + } + + public override fun applyOptions(context: Context, builder: GlideBuilder): Unit { + appGlideModule.applyOptions(context, builder) + } + + public override fun isManifestParsingEnabled(): Boolean = false +} +""" + +// generated code always includes public and Unit +@Suppress("RedundantVisibilityModifier", "RedundantUnitReturnType") +@Language("kotlin") +const val appGlideModuleWithOnlyDependencyLibraryModules = + """ +package com.bumptech.glide + +import AppModule +import android.content.Context +import com.bumptech.glide.integration.okhttp3.OkHttpLibraryGlideModule +import kotlin.Boolean +import kotlin.Suppress +import kotlin.Unit + +internal class GeneratedAppGlideModuleImpl( + @Suppress("UNUSED_PARAMETER") + context: Context, +) : GeneratedAppGlideModule() { + private val appGlideModule: AppModule + init { + appGlideModule = AppModule() + } + + public override fun registerComponents( + context: Context, + glide: Glide, + registry: Registry, + ): Unit { + OkHttpLibraryGlideModule().registerComponents(context, glide, registry) + appGlideModule.registerComponents(context, glide, registry) + } + + public override fun applyOptions(context: Context, builder: GlideBuilder): Unit { + appGlideModule.applyOptions(context, builder) + } + + public override fun isManifestParsingEnabled(): Boolean = false +} +""" diff --git a/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/AppGlideModules.kt b/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/AppGlideModules.kt new file mode 100644 index 0000000000..f470bfdb8a --- /dev/null +++ b/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/AppGlideModules.kt @@ -0,0 +1,392 @@ +package com.bumptech.glide.annotation.ksp + +import com.bumptech.glide.annotation.Excludes +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getConstructors +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSNode +import com.google.devtools.ksp.symbol.KSType +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.TypeSpec +import kotlin.reflect.KClass + +// This class is visible only for testing +// TODO(b/174783094): Add @VisibleForTesting when internal is supported. +object AppGlideModuleConstants { + // This variable is visible only for testing + // TODO(b/174783094): Add @VisibleForTesting when internal is supported. + const val INVALID_MODULE_MESSAGE = + "Your AppGlideModule must have at least one constructor that has either no parameters or " + + "accepts only a Context." + // This variable is visible only for testing + // TODO(b/174783094): Add @VisibleForTesting when internal is supported. + const val INVALID_EXCLUDES_ANNOTATION_MESSAGE = + """ + @Excludes on %s is invalid. The value argument of your @Excludes annotation must be set to + either a single LibraryGlideModule class or a non-empty list of LibraryGlideModule classes. + Remove the annotation if you do not wish to exclude any LibraryGlideModules. Include each + LibraryGlideModule you do wish to exclude exactly once. Do not put types other than + LibraryGlideModules in the argument list""" + + private const val CONTEXT_NAME = "Context" + private const val CONTEXT_PACKAGE = "android.content" + internal const val GLIDE_PACKAGE_NAME = "com.bumptech.glide" + internal const val CONTEXT_QUALIFIED_NAME = "$CONTEXT_PACKAGE.$CONTEXT_NAME" + internal const val GENERATED_ROOT_MODULE_PACKAGE_NAME = GLIDE_PACKAGE_NAME + + internal val CONTEXT_CLASS_NAME = ClassName(CONTEXT_PACKAGE, CONTEXT_NAME) +} + +internal data class AppGlideModuleData( + val name: ClassName, + val constructor: Constructor, + val allowedLibraryGlideModuleNames: List, + val sources: List, +) { + internal data class Constructor(val hasContext: Boolean) +} + +/** + * Given a [com.bumptech.glide.module.AppGlideModule] class declaration provided by the developer, + * validate the class and produce a fully parsed [AppGlideModuleData] that allows us to generate a + * valid [com.bumptech.glide.GeneratedAppGlideModule] implementation without further introspection. + */ +internal class AppGlideModuleParser( + private val environment: SymbolProcessorEnvironment, + private val resolver: Resolver, + private val appGlideModuleClass: KSClassDeclaration, +) { + + fun parseAppGlideModule(): AppGlideModuleData { + val constructor = parseAppGlideModuleConstructorOrThrow() + val name = ClassName.bestGuess(appGlideModuleClass.qualifiedName!!.asString()) + + val (indexFiles, allLibraryModuleNames) = getIndexesAndLibraryGlideModuleNames() + val excludedGlideModuleClassNames = getExcludedGlideModuleClassNames() + val filteredGlideModuleClassNames = + allLibraryModuleNames.filterNot { excludedGlideModuleClassNames.contains(it) } + + return AppGlideModuleData( + name = name, + constructor = constructor, + allowedLibraryGlideModuleNames = filteredGlideModuleClassNames, + sources = indexFiles, + ) + } + + private fun getExcludedGlideModuleClassNames(): Set { + val excludesAnnotation = + appGlideModuleClass.atMostOneExcludesAnnotation() ?: return emptySet() + environment.logger.logging( + "Found excludes annotation arguments: ${excludesAnnotation.arguments}" + ) + return parseExcludesAnnotationArgumentsOrNull(excludesAnnotation) + ?: throw InvalidGlideSourceException( + AppGlideModuleConstants.INVALID_EXCLUDES_ANNOTATION_MESSAGE.format( + appGlideModuleClass.qualifiedName?.asString() + ) + ) + } + + /** + * Given a list of arguments from an [com.bumptech.glide.annotation.Excludes] annotation, parses + * and returns a list of qualified names of the excluded + * [com.bumptech.glide.module.LibraryGlideModule] implementations, or returns null if the + * arguments are invalid. + * + * Ideally we'd throw more specific exceptions based on the type of failure. However, there are + * a bunch of individual failure types and they differ depending on whether the source was + * written in Java or Kotlin. Rather than trying to describe every failure in detail, we'll just + * return null and allow callers to describe the correct behavior. + */ + private fun parseExcludesAnnotationArgumentsOrNull( + excludesAnnotation: KSAnnotation + ): Set? { + val valueArguments: List? = excludesAnnotation.valueArgumentList() + if (valueArguments == null || valueArguments.isEmpty()) { + return null + } + if (valueArguments.any { !it.extendsLibraryGlideModule() }) { + return null + } + val libraryGlideModuleNames = + valueArguments.mapNotNull { it.declaration.qualifiedName?.asString() } + if (libraryGlideModuleNames.size != valueArguments.size) { + return null + } + val uniqueLibraryGlideModuleNames = libraryGlideModuleNames.toSet() + if (uniqueLibraryGlideModuleNames.size != valueArguments.size) { + return null + } + return uniqueLibraryGlideModuleNames + } + + private fun KSType.extendsLibraryGlideModule(): Boolean = + ModuleParser.extractGlideModules(listOf(declaration)).libraryModules.size == 1 + + /** + * Parses the `value` argument as a list of the given type, or returns `null` if the annotation + * has multiple arguments or `value` has any entries that are not of the expected type `T`. + * + * `value` is the name of the default annotation parameter allowed by syntax like + * `@Excludes(argument)` or `@Excludes(argument1, argument2)` or `@Excludes({argument1, + * argument2})`, depending on the source type (Kotlin or Java). This method requires that the + * annotation has exactly one `value` argument of a given type and standardizes the differences + * KSP produces between Kotlin and Java source. + * + * To make this function more general purpose, we should assert that the values are of type T + * rather just returning null. For our current single use case, returning null matches the use + * case for the caller better than throwing. + */ + private inline fun KSAnnotation.valueArgumentList(): List? { + // Require that the annotation has a single value argument that points either to a single + // thing + // or a list of things (A or [A, B, C]). First validate that there's exactly one parameter + // and + // that it has the expected name. + // e.g. @Excludes(value = (A or [A, B, C])) -> (A or [A, B, C]) + val valueParameterValue: Any? = + arguments.singleOrNull().takeIf { it?.name?.asString() == "value" }?.value + + // Next unify the types by verifying that it either has a single value of T, or a List of + // T and converting both to List + // (A or [A, B, C]) -> ([A] or [A, B, C]) with the correct types + return when (valueParameterValue) { + is List<*> -> valueParameterValue.asListGivenTypeOfOrNull() + is T -> listOf(valueParameterValue) + else -> null + } + } + + private inline fun List<*>.asListGivenTypeOfOrNull(): List? = + filterIsInstance(T::class.java).takeIf { it.size == size } + + private fun parseAppGlideModuleConstructorOrThrow(): AppGlideModuleData.Constructor { + val hasEmptyConstructors = + appGlideModuleClass.getConstructors().any { it.parameters.isEmpty() } + val hasContextParamOnlyConstructor = + appGlideModuleClass.getConstructors().any { it.hasSingleContextParameter() } + if (!hasEmptyConstructors && !hasContextParamOnlyConstructor) { + throw InvalidGlideSourceException(AppGlideModuleConstants.INVALID_MODULE_MESSAGE) + } + return AppGlideModuleData.Constructor(hasContextParamOnlyConstructor) + } + + private fun KSFunctionDeclaration.hasSingleContextParameter() = + parameters.size == 1 && + AppGlideModuleConstants.CONTEXT_QUALIFIED_NAME == + parameters.single().type.resolve().declaration.qualifiedName?.asString() + + private data class IndexFilesAndLibraryModuleNames( + val indexFiles: List, + val libraryModuleNames: List, + ) + + @OptIn(KspExperimental::class) + private fun getIndexesAndLibraryGlideModuleNames(): IndexFilesAndLibraryModuleNames { + val allIndexFiles: MutableList = mutableListOf() + val allLibraryGlideModuleNames: MutableList = mutableListOf() + + val allIndexesAndLibraryModules = + getAllLibraryNamesFromJavaIndexes() + getAllLibraryNamesFromKspIndexes() + for ((index, libraryGlideModuleNames) in allIndexesAndLibraryModules) { + allIndexFiles.add(index) + allLibraryGlideModuleNames.addAll(libraryGlideModuleNames) + } + + return IndexFilesAndLibraryModuleNames(allIndexFiles, allLibraryGlideModuleNames) + } + + internal data class IndexAndLibraryModuleNames( + val index: KSDeclaration, + val libraryModuleNames: List, + ) + + private fun getAllLibraryNamesFromKspIndexes(): List = + getAllLibraryNamesFromIndexes(GlideSymbolProcessorConstants.PACKAGE_NAME) { index -> + extractGlideModulesFromKspIndexAnnotation(index) + } + + private fun getAllLibraryNamesFromJavaIndexes(): List = + getAllLibraryNamesFromIndexes(GlideSymbolProcessorConstants.JAVA_ANNOTATION_PACKAGE_NAME) { + index -> + extractGlideModulesFromJavaIndexAnnotation(index) + } + + @OptIn(KspExperimental::class) + private fun getAllLibraryNamesFromIndexes( + packageName: String, + extractLibraryModuleNamesFromIndex: (KSDeclaration) -> List, + ) = buildList { + resolver.getDeclarationsFromPackage(packageName).forEach { index: KSDeclaration -> + val libraryGlideModuleNames = extractLibraryModuleNamesFromIndex(index) + if (libraryGlideModuleNames.isNotEmpty()) { + environment.logger.info( + "Found index annotation: $index with modules: $libraryGlideModuleNames" + ) + add(IndexAndLibraryModuleNames(index, libraryGlideModuleNames)) + } + } + } + + private fun extractGlideModulesFromJavaIndexAnnotation(index: KSDeclaration): List { + val indexAnnotation: KSAnnotation = + index.atMostOneJavaIndexAnnotation() ?: return emptyList() + return indexAnnotation.getModuleArgumentValues().toList() + } + + private fun extractGlideModulesFromKspIndexAnnotation(index: KSDeclaration): List { + val indexAnnotation: KSAnnotation = + index.atMostOneKspIndexAnnotation() ?: return emptyList() + return indexAnnotation.getModuleArgumentValues().toList() + } + + private fun KSAnnotation.getModuleArgumentValues(): List { + val result = + arguments + .find { it.name?.getShortName().equals(IndexGenerator.INDEX_MODULES_NAME) } + ?.value + if (result is List<*> && result.all { it is String }) { + @Suppress("UNCHECKED_CAST") + return result as List + } + throw InvalidGlideSourceException("Found an invalid internal Glide index: $this") + } + + private fun KSDeclaration.atMostOneJavaIndexAnnotation() = + atMostOneAnnotation("com.bumptech.glide.annotation.compiler.Index") + + private fun KSDeclaration.atMostOneKspIndexAnnotation() = atMostOneAnnotation(Index::class) + + private fun KSDeclaration.atMostOneExcludesAnnotation() = atMostOneAnnotation(Excludes::class) + + private fun KSDeclaration.atMostOneAnnotation( + annotation: KClass + ): KSAnnotation? = atMostOneAnnotation(annotation.qualifiedName) + + private fun KSDeclaration.atMostOneAnnotation(annotationQualifiedName: String?): KSAnnotation? { + val matchingAnnotations: List = + annotations + .filter { + annotationQualifiedName?.equals( + it.annotationType.resolve().declaration.qualifiedName?.asString() + ) ?: false + } + .toList() + if (matchingAnnotations.size > 1) { + throw InvalidGlideSourceException( + """Expected 0 or 1 $annotationQualifiedName annotations on $qualifiedName, but found: + ${matchingAnnotations.size}""" + ) + } + return matchingAnnotations.singleOrNull() + } +} + +/** + * Given valid [AppGlideModuleData], writes a Kotlin implementation of + * [com.bumptech.glide.GeneratedAppGlideModule]. + * + * This class should obtain all of its data from [AppGlideModuleData] and should not interact with + * any ksp classes. In the long run, the restriction may allow us to share code between the Java and + * Kotlin processors. + */ +internal class AppGlideModuleGenerator(private val appGlideModuleData: AppGlideModuleData) { + + fun generateAppGlideModule(): FileSpec { + val generatedAppGlideModuleClass = generateAppGlideModuleClass(appGlideModuleData) + return FileSpec.builder( + AppGlideModuleConstants.GLIDE_PACKAGE_NAME, + "GeneratedAppGlideModuleImpl", + ) + .addType(generatedAppGlideModuleClass) + .build() + } + + private fun generateAppGlideModuleClass(data: AppGlideModuleData): TypeSpec { + return TypeSpec.classBuilder("GeneratedAppGlideModuleImpl") + .superclass( + ClassName( + AppGlideModuleConstants.GENERATED_ROOT_MODULE_PACKAGE_NAME, + "GeneratedAppGlideModule", + ) + ) + .addModifiers(KModifier.INTERNAL) + .addProperty("appGlideModule", data.name, KModifier.PRIVATE) + .primaryConstructor(generateConstructor(data)) + .addFunction(generateRegisterComponents(data.allowedLibraryGlideModuleNames)) + .addFunction(generateApplyOptions()) + .addFunction(generateManifestParsingDisabled()) + .build() + } + + private fun generateConstructor(data: AppGlideModuleData): FunSpec { + val contextParameterBuilder = + ParameterSpec.builder("context", AppGlideModuleConstants.CONTEXT_CLASS_NAME) + if (!data.constructor.hasContext) { + contextParameterBuilder.addAnnotation( + AnnotationSpec.builder(ClassName("kotlin", "Suppress")) + .addMember("%S", "UNUSED_PARAMETER") + .build() + ) + } + + return FunSpec.constructorBuilder() + .addParameter(contextParameterBuilder.build()) + .addStatement( + "appGlideModule = %T(${if (data.constructor.hasContext) "context" else ""})", + data.name, + ) + .build() + + // TODO(judds): Log the discovered modules here. + } + + private fun generateRegisterComponents(allowedGlideModuleNames: List) = + FunSpec.builder("registerComponents") + .addModifiers(KModifier.PUBLIC, KModifier.OVERRIDE) + .addParameter("context", AppGlideModuleConstants.CONTEXT_CLASS_NAME) + .addParameter("glide", ClassName(AppGlideModuleConstants.GLIDE_PACKAGE_NAME, "Glide")) + .addParameter( + "registry", + ClassName(AppGlideModuleConstants.GLIDE_PACKAGE_NAME, "Registry"), + ) + .apply { + allowedGlideModuleNames.forEach { + addStatement( + "%T().registerComponents(context, glide, registry)", + ClassName.bestGuess(it), + ) + } + } + .addStatement("appGlideModule.registerComponents(context, glide, registry)") + .build() + + private fun generateApplyOptions() = + FunSpec.builder("applyOptions") + .addModifiers(KModifier.PUBLIC, KModifier.OVERRIDE) + .addParameter("context", AppGlideModuleConstants.CONTEXT_CLASS_NAME) + .addParameter( + "builder", + ClassName(AppGlideModuleConstants.GLIDE_PACKAGE_NAME, "GlideBuilder"), + ) + .addStatement("appGlideModule.applyOptions(context, builder)") + .build() + + private fun generateManifestParsingDisabled() = + FunSpec.builder("isManifestParsingEnabled") + .addModifiers(KModifier.PUBLIC, KModifier.OVERRIDE) + .returns(Boolean::class) + .addStatement("return false") + .build() +} diff --git a/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/GlideSymbolProcessor.kt b/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/GlideSymbolProcessor.kt new file mode 100644 index 0000000000..1d3993ddc2 --- /dev/null +++ b/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/GlideSymbolProcessor.kt @@ -0,0 +1,147 @@ +package com.bumptech.glide.annotation.ksp + +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFile +import com.google.devtools.ksp.validate +import com.squareup.kotlinpoet.FileSpec + +/** + * Glide's KSP annotation processor. + * + * This class recognizes and parses [com.bumptech.glide.module.AppGlideModule]s and + * [com.bumptech.glide.module.LibraryGlideModule]s that are annotated with + * [com.bumptech.glide.annotation.GlideModule]. + * + * `LibraryGlideModule`s are merged into indexes, or classes generated in Glide's package. When a + * `AppGlideModule` is found, we then generate Glide's configuration so that it calls the + * `AppGlideModule` and any included `LibraryGlideModules`. Using indexes allows us to process + * `LibraryGlideModules` in multiple rounds and/or libraries. + */ +class GlideSymbolProcessor(private val environment: SymbolProcessorEnvironment) : SymbolProcessor { + private var isAppGlideModuleGenerated = false + + override fun process(resolver: Resolver): List { + val symbols = resolver.getSymbolsWithAnnotation("com.bumptech.glide.annotation.GlideModule") + val (validSymbols, invalidSymbols) = symbols.partition { it.validate() }.toList() + return try { + processChecked(resolver, symbols, validSymbols, invalidSymbols) + } catch (e: InvalidGlideSourceException) { + environment.logger.error(e.userMessage) + invalidSymbols + } + } + + private fun processChecked( + resolver: Resolver, + symbols: Sequence, + validSymbols: List, + invalidSymbols: List, + ): List { + environment.logger.logging("Found symbols, valid: $validSymbols, invalid: $invalidSymbols") + + val (appGlideModules, libraryGlideModules) = ModuleParser.extractGlideModules(validSymbols) + + if (libraryGlideModules.size + appGlideModules.size != validSymbols.count()) { + val invalidModules = + symbols + .filter { !libraryGlideModules.contains(it) && !appGlideModules.contains(it) } + .map { it.location.toString() } + .toList() + + throw InvalidGlideSourceException( + GlideSymbolProcessorConstants.INVALID_ANNOTATED_CLASS.format(invalidModules) + ) + } + + if (appGlideModules.size > 1) { + throw InvalidGlideSourceException( + GlideSymbolProcessorConstants.SINGLE_APP_MODULE_ERROR.format(appGlideModules) + ) + } + + environment.logger.logging( + "Found AppGlideModules: $appGlideModules, LibraryGlideModules: $libraryGlideModules" + ) + + if (libraryGlideModules.isNotEmpty()) { + if (isAppGlideModuleGenerated) { + throw InvalidGlideSourceException( + """Found $libraryGlideModules LibraryGlideModules after processing the AppGlideModule. + If you generated these LibraryGlideModules via another annotation processing, either + don't or also generate the AppGlideModule and do so in the same round as the + LibraryGlideModules or in a subsequent round""" + ) + } + parseLibraryModulesAndWriteIndex(libraryGlideModules) + return invalidSymbols + appGlideModules + } + + if (appGlideModules.isNotEmpty()) { + parseAppGlideModuleAndIndexesAndWriteGeneratedAppGlideModule( + resolver, + appGlideModules.single(), + ) + } + + return invalidSymbols + } + + private fun parseAppGlideModuleAndIndexesAndWriteGeneratedAppGlideModule( + resolver: Resolver, + appGlideModule: KSClassDeclaration, + ) { + val appGlideModuleData = + AppGlideModuleParser(environment, resolver, appGlideModule).parseAppGlideModule() + val appGlideModuleGenerator = AppGlideModuleGenerator(appGlideModuleData) + val appGlideModuleFileSpec: FileSpec = appGlideModuleGenerator.generateAppGlideModule() + val sources = appGlideModuleData.sources.mapNotNull { it.containingFile }.toMutableList() + if (appGlideModule.containingFile != null) { + sources.add(appGlideModule.containingFile!!) + } + writeFile(appGlideModuleFileSpec, sources) + } + + private fun parseLibraryModulesAndWriteIndex( + libraryGlideModuleClassDeclarations: List + ) { + val libraryGlideModulesParser = + LibraryGlideModulesParser(environment, libraryGlideModuleClassDeclarations) + val uniqueLibraryGlideModules = libraryGlideModulesParser.parseUnique() + val index: FileSpec = IndexGenerator.generate(uniqueLibraryGlideModules.map { it.name }) + writeFile(index, uniqueLibraryGlideModules.mapNotNull { it.containingFile }) + } + + private fun writeFile(file: FileSpec, sources: List) { + environment.codeGenerator + .createNewFile( + Dependencies(aggregating = false, sources = sources.toTypedArray()), + file.packageName, + file.name, + ) + .writer() + .use { file.writeTo(it) } + + environment.logger.logging("Wrote file: $file") + } +} + +// This class is visible only for testing +// TODO(b/174783094): Add @VisibleForTesting when internal is supported. +object GlideSymbolProcessorConstants { + // This variable is visible only for testing + // TODO(b/174783094): Add @VisibleForTesting when internal is supported. + val PACKAGE_NAME: String = GlideSymbolProcessor::class.java.`package`.name + val JAVA_ANNOTATION_PACKAGE_NAME: String = "com.bumptech.glide.annotation.compiler" + const val SINGLE_APP_MODULE_ERROR = "You can have at most one AppGlideModule, but found: %s" + const val DUPLICATE_LIBRARY_MODULE_ERROR = + "LibraryGlideModules %s are included more than once, keeping only one!" + const val INVALID_ANNOTATED_CLASS = + "@GlideModule annotated classes must implement AppGlideModule or LibraryGlideModule: %s" +} + +internal class InvalidGlideSourceException(val userMessage: String) : Exception(userMessage) diff --git a/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/GlideSymbolProcessorProvider.kt b/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/GlideSymbolProcessorProvider.kt new file mode 100644 index 0000000000..e68709f450 --- /dev/null +++ b/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/GlideSymbolProcessorProvider.kt @@ -0,0 +1,13 @@ +package com.bumptech.glide.annotation.ksp + +import com.google.auto.service.AutoService +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +@AutoService(SymbolProcessorProvider::class) +class GlideSymbolProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return GlideSymbolProcessor(environment) + } +} diff --git a/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/LibraryGlideModules.kt b/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/LibraryGlideModules.kt new file mode 100644 index 0000000000..e0f0af717e --- /dev/null +++ b/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/LibraryGlideModules.kt @@ -0,0 +1,142 @@ +package com.bumptech.glide.annotation.ksp + +import com.bumptech.glide.annotation.GlideModule +import com.bumptech.glide.annotation.ksp.LibraryGlideModuleData.LibraryModuleName +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFile +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.DelicateKotlinPoetApi +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.TypeSpec +import java.util.UUID + +internal data class LibraryGlideModuleData( + val name: LibraryModuleName, + val containingFile: KSFile?, +) { + data class LibraryModuleName(val qualifiedName: String) +} + +internal class LibraryGlideModulesParser( + private val environment: SymbolProcessorEnvironment, + private val libraryGlideModules: List, +) { + init { + require(libraryGlideModules.isNotEmpty()) + } + + fun parseUnique(): List { + val allLibraryGlideModules = + libraryGlideModules + .map { + LibraryGlideModuleData( + LibraryModuleName(it.qualifiedName!!.asString()), + it.containingFile, + ) + } + .toList() + val uniqueLibraryGlideModules = + allLibraryGlideModules.associateBy { it.name }.values.sortedBy { it.name.qualifiedName } + if (uniqueLibraryGlideModules.size != libraryGlideModules.size) { + // Find the set of modules that have been included more than once by mapping the + // qualified + // name of the module to a count of the number of times it's been seen. Duplicates are + // then + // any keys that have a value > 1. + val duplicateModules: List = + allLibraryGlideModules + .groupingBy { it.name.qualifiedName } + .eachCount() + .filter { it.value > 1 } + .keys + .toList() + environment.logger.warn( + GlideSymbolProcessorConstants.DUPLICATE_LIBRARY_MODULE_ERROR.format( + duplicateModules + ) + ) + } + + return uniqueLibraryGlideModules + } +} + +/** + * Generates an empty class with an annotation containing the class names of one or more + * LibraryGlideModules and/or one or more GlideExtensions. + * + * We use a separate class so that LibraryGlideModules and GlideExtensions written in libraries can + * be bundled into an AAR and later retrieved by the annotation processor when it processes the + * AppGlideModule in an application. + * + * The output file generated by this class with a single LibraryGlideModule looks like this: + * ``` + * @com.bumptech.glide.annotation.ksp.Index( + * ["com.bumptech.glide.integration.okhttp3.OkHttpLibraryGlideModule"] + * ) + * class Indexer_GlideModule_com_bumptech_glide_integration_okhttp3_OkHttpLibraryGlideModule + * ``` + * + * This class is not a public API and used only internally by the processor. + */ +internal object IndexGenerator { + private const val INDEXER_NAME_PREFIX = "GlideIndexer_" + private const val MAXIMUM_FILE_NAME_LENGTH = 255 + + // The name of the parameter in the Index annotation that points to the list of modules + internal const val INDEX_MODULES_NAME = "modules" + + @OptIn(DelicateKotlinPoetApi::class) // For AnnotationSpec.builder + fun generate(libraryModuleNames: List): FileSpec { + val libraryModuleQualifiedNames: List = libraryModuleNames.map { it.qualifiedName } + + val indexAnnotation: AnnotationSpec = + AnnotationSpec.builder(Index::class.java) + .addRepeatedMember(INDEX_MODULES_NAME, libraryModuleQualifiedNames) + .build() + val indexName = generateUniqueName(libraryModuleQualifiedNames) + + return FileSpec.builder(GlideSymbolProcessorConstants.PACKAGE_NAME, indexName) + .addType(TypeSpec.classBuilder(indexName).addAnnotation(indexAnnotation).build()) + .build() + } + + private fun generateUniqueName(libraryModuleQualifiedNames: List): String { + val glideModuleBasedName = generateNameFromLibraryModules(libraryModuleQualifiedNames) + + // If the indexer name has too many packages/modules, it can exceed the file name length + // allowed by the file system, which can break compilation. To avoid that, fall back to a + // deterministic UUID. + return if (glideModuleBasedName.exceedsFileSystemMaxNameLength()) { + generateShortUUIDBasedName(glideModuleBasedName) + } else { + glideModuleBasedName + } + } + + private fun String.exceedsFileSystemMaxNameLength() = + length >= MAXIMUM_FILE_NAME_LENGTH - INDEXER_NAME_PREFIX.length + + private fun generateShortUUIDBasedName(glideModuleBasedName: String) = + INDEXER_NAME_PREFIX + + UUID.nameUUIDFromBytes(glideModuleBasedName.toByteArray()).toString().replace("-", "_") + + private fun generateNameFromLibraryModules(libraryModuleQualifiedNames: List): String { + return libraryModuleQualifiedNames.joinToString( + prefix = INDEXER_NAME_PREFIX + GlideModule::class.java.simpleName + "_", + separator = "_", + ) { + it.replace(".", "_") + } + } + + private fun AnnotationSpec.Builder.addRepeatedMember( + name: String, + repeatedMember: List, + ) = + addMember( + "$name = [\n" + "%S,\n".repeat(repeatedMember.size) + "]", + *repeatedMember.toTypedArray(), + ) +} diff --git a/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/ModuleParser.kt b/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/ModuleParser.kt new file mode 100644 index 0000000000..bf04bd383f --- /dev/null +++ b/annotation/ksp/src/main/kotlin/com/bumptech/glide/annotation/ksp/ModuleParser.kt @@ -0,0 +1,41 @@ +package com.bumptech.glide.annotation.ksp + +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSNode + +object ModuleParser { + + internal data class GlideModules( + val appModules: List, + val libraryModules: List, + ) + + internal fun extractGlideModules(annotatedModules: List): GlideModules { + val appAndLibraryModuleNames = + listOf(APP_MODULE_QUALIFIED_NAME, LIBRARY_MODULE_QUALIFIED_NAME) + val modulesBySuperType: Map> = + annotatedModules.filterIsInstance().groupBy { classDeclaration -> + appAndLibraryModuleNames.firstOrNull { classDeclaration.hasSuperType(it) } + } + + val (appModules, libraryModules) = + appAndLibraryModuleNames.map { modulesBySuperType[it] ?: emptyList() } + return GlideModules(appModules, libraryModules) + } + + private fun KSClassDeclaration.hasSuperType(superTypeQualifiedName: String): Boolean { + val superDeclarations = superTypes.map { superType -> superType.resolve().declaration } + val hasInDirectParent = + superDeclarations.map { it.qualifiedName!!.asString() }.contains(superTypeQualifiedName) + return if (hasInDirectParent) { + true + } else { + superDeclarations.filterIsInstance(KSClassDeclaration::class.java).any { + it.hasSuperType(superTypeQualifiedName) + } + } + } + + private const val APP_MODULE_QUALIFIED_NAME = "com.bumptech.glide.module.AppGlideModule" + private const val LIBRARY_MODULE_QUALIFIED_NAME = "com.bumptech.glide.module.LibraryGlideModule" +} diff --git a/annotation/ksp/test/build.gradle.kts b/annotation/ksp/test/build.gradle.kts new file mode 100644 index 0000000000..4058482baa --- /dev/null +++ b/annotation/ksp/test/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "com.bumptech.glide.annotation.ksp.test" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +kotlin { + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(11)) + } +} + +dependencies { + implementation(libs.junit) + implementation(project(":annotation:ksp")) + implementation(libs.ksp.compiletesting) + implementation(libs.truth) + + testImplementation(project(":annotation:ksp")) + testImplementation(project(":annotation")) + testImplementation(project(":glide")) + testImplementation(libs.kotlin.test) +} + +tasks.withType().configureEach { + enabled = false +} diff --git a/annotation/ksp/test/src/main/kotlin/com/bumptech/glide/annotation/ksp/test/SourceTestHelpers.kt b/annotation/ksp/test/src/main/kotlin/com/bumptech/glide/annotation/ksp/test/SourceTestHelpers.kt new file mode 100644 index 0000000000..326deec0be --- /dev/null +++ b/annotation/ksp/test/src/main/kotlin/com/bumptech/glide/annotation/ksp/test/SourceTestHelpers.kt @@ -0,0 +1,169 @@ +package com.bumptech.glide.annotation.ksp.test + +import com.bumptech.glide.annotation.ksp.GlideSymbolProcessorProvider +import com.google.common.truth.StringSubject +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.kspSourcesDir +import com.tschuchort.compiletesting.symbolProcessorProviders +import java.io.File +import java.io.FileNotFoundException +import org.intellij.lang.annotations.Language +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi + +@OptIn(ExperimentalCompilerApi::class) +class CompilationResult( + private val compilation: KotlinCompilation, + result: KotlinCompilation.Result, +) { + val exitCode = result.exitCode + val messages = result.messages + + fun generatedAppGlideModuleContents() = readFile(findAppGlideModule()) + + fun allGeneratedFiles(): List { + val allFiles = mutableListOf() + val parentDir = generatedFilesParentDir() + if (parentDir != null) { + findAllFilesRecursive(parentDir, allFiles) + } + return allFiles + } + + private fun findAllFilesRecursive(parent: File, allFiles: MutableList) { + if (parent.isFile) { + allFiles.add(parent) + return + } + parent.listFiles()?.map { findAllFilesRecursive(it, allFiles) } + } + + private fun generatedFilesParentDir(): File? { + var currentDir: File? = compilation.kspSourcesDir + listOf("kotlin", "com", "bumptech", "glide").forEach { directoryName -> + currentDir = currentDir?.listFiles()?.find { it.name.equals(directoryName) } + } + return currentDir + } + + private fun readFile(file: File) = file.readLines().joinToString("\n") + + private fun findAppGlideModule(): File { + return generatedFilesParentDir()?.listFiles()?.find { + it.name.equals("GeneratedAppGlideModuleImpl.kt") + } + ?: throw FileNotFoundException( + "GeneratedAppGlideModuleImpl.kt was not generated or not generated in the expected" + + "location" + ) + } +} + +enum class SourceType { + KOTLIN, + JAVA +} + +sealed interface TypedSourceFile { + fun sourceFile(): SourceFile + fun sourceType(): SourceType +} + +class GeneratedSourceFile( + private val file: File, + private val currentSourceType: SourceType, +) : TypedSourceFile { + override fun sourceFile(): SourceFile = SourceFile.fromPath(file) + + // Hack alert: We use this class only for generated output of some previous compilation. We rely + // on the type in that previous compilation to select the proper source. The output however is + // always Kotlin, regardless of source. But we always want to include whatever the generated + // output is in the next step. That means we need our sourceType here to match the + // currentSourceType in the test. + override fun sourceType(): SourceType = currentSourceType +} + +class KotlinSourceFile( + val name: String, + @Language("kotlin") val content: String, +) : TypedSourceFile { + override fun sourceFile() = SourceFile.kotlin(name, content) + override fun sourceType() = SourceType.KOTLIN +} + +class JavaSourceFile( + val name: String, + @Language("java") val content: String, +) : TypedSourceFile { + override fun sourceFile() = SourceFile.java(name, content) + override fun sourceType() = SourceType.JAVA +} + +interface PerSourceTypeTest { + val sourceType: SourceType + + fun compileCurrentSourceType( + vararg sourceFiles: TypedSourceFile, + test: (input: CompilationResult) -> Unit = {}, + ): CompilationResult { + val result = + compile(sourceFiles.filter { it.sourceType() == sourceType }.map { it.sourceFile() }.toList()) + test(result) + return result + } +} + +@OptIn(ExperimentalCompilerApi::class) +internal fun compile(sourceFiles: List): CompilationResult { + require(sourceFiles.isNotEmpty()) + val compilation = + KotlinCompilation().apply { + sources = sourceFiles + symbolProcessorProviders = listOf(GlideSymbolProcessorProvider()) + inheritClassPath = true + } + val result = compilation.compile() + return CompilationResult(compilation, result) +} + +fun StringSubject.hasSourceEqualTo(sourceContents: String) = isEqualTo(sourceContents.trimIndent()) + +object CommonSources { + // generated code always includes public and Unit + @Suppress("RedundantVisibilityModifier", "RedundantUnitReturnType") + @Language("kotlin") + const val simpleAppGlideModule = + """ +package com.bumptech.glide + +import AppModule +import android.content.Context +import kotlin.Boolean +import kotlin.Suppress +import kotlin.Unit + +internal class GeneratedAppGlideModuleImpl( + @Suppress("UNUSED_PARAMETER") + context: Context, +) : GeneratedAppGlideModule() { + private val appGlideModule: AppModule + init { + appGlideModule = AppModule() + } + + public override fun registerComponents( + context: Context, + glide: Glide, + registry: Registry, + ): Unit { + appGlideModule.registerComponents(context, glide, registry) + } + + public override fun applyOptions(context: Context, builder: GlideBuilder): Unit { + appGlideModule.applyOptions(context, builder) + } + + public override fun isManifestParsingEnabled(): Boolean = false +} +""" +} diff --git a/annotation/ksp/test/src/test/kotlin/com/bumptech/glide/annotation/ksp/test/LibraryGlideModuleTests.kt b/annotation/ksp/test/src/test/kotlin/com/bumptech/glide/annotation/ksp/test/LibraryGlideModuleTests.kt new file mode 100644 index 0000000000..84fb807bdb --- /dev/null +++ b/annotation/ksp/test/src/test/kotlin/com/bumptech/glide/annotation/ksp/test/LibraryGlideModuleTests.kt @@ -0,0 +1,854 @@ +package com.bumptech.glide.annotation.ksp.test + +import com.bumptech.glide.annotation.ksp.AppGlideModuleConstants +import com.bumptech.glide.annotation.ksp.GlideSymbolProcessorConstants +import com.google.common.truth.Truth.assertThat +import com.tschuchort.compiletesting.KotlinCompilation.ExitCode +import java.io.FileNotFoundException +import kotlin.test.assertFailsWith +import org.intellij.lang.annotations.Language +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters + +@RunWith(Parameterized::class) +@OptIn(ExperimentalCompilerApi::class) +class LibraryGlideModuleTests(override val sourceType: SourceType) : PerSourceTypeTest { + + companion object { + @Parameters(name = "sourceType = {0}") @JvmStatic fun data() = SourceType.values() + } + + @Test + fun compile_withAnnotatedAndValidLibraryGlideModule_succeeds_butDoesNotGenerateGeneratedAppGlideModule() { + val kotlinModule = + KotlinSourceFile( + "Module.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class Module : LibraryGlideModule() + """, + ) + val javaModule = + JavaSourceFile( + "Module.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.LibraryGlideModule; + + @GlideModule public class Module extends LibraryGlideModule {} + """, + ) + + compileCurrentSourceType(kotlinModule, javaModule) { + assertThat(it.messages).doesNotContainMatch("[we]: \\[ksp] .*") + assertThat(it.exitCode).isEqualTo(ExitCode.OK) + assertFailsWith { it.generatedAppGlideModuleContents() } + } + } + + @Test + fun compile_withValidLibraryGlideModule_andAppGlideModule_generatesGeneratedAppGlideModule_andCallsBothLibraryAndAppGlideModules() { + val kotlinLibraryModule = + KotlinSourceFile( + "LibraryModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class LibraryModule : LibraryGlideModule() + """, + ) + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule class AppModule : AppGlideModule() + """, + ) + val javaLibraryModule = + JavaSourceFile( + "LibraryModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.LibraryGlideModule; + + @GlideModule public class LibraryModule extends LibraryGlideModule {} + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule public class AppModule extends AppGlideModule { + public AppModule() {} + } + """, + ) + + compileCurrentSourceType( + kotlinAppModule, + kotlinLibraryModule, + javaAppModule, + javaLibraryModule, + ) { + assertThat(it.messages).doesNotContainMatch("[we]: \\[ksp] .*") + assertThat(it.exitCode).isEqualTo(ExitCode.OK) + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(appGlideModuleWithLibraryModule) + } + } + + @Test + fun compile_withValidLibraryGlideModule_andAppGlideModule_ThroughBaseClass_generatesGeneratedAppGlideModule_andCallsBothLibraryAndAppGlideModules() { + val kotlinLibraryModule = + KotlinSourceFile( + "LibraryModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + class BaseLibraryModule : LibraryGlideModule() + @GlideModule class LibraryModule : BaseLibraryModule() + """, + ) + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + class BaseAppModule : AppGlideModule() + @GlideModule class AppModule : BaseAppModule() + """, + ) + val javaBaseLibraryModule = + JavaSourceFile( + "BaseLibraryModule.java", + """ + import com.bumptech.glide.module.LibraryGlideModule; + + public class BaseLibraryModule extends LibraryGlideModule {} + """, + ) + val javaLibraryModule = + JavaSourceFile( + "LibraryModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + + @GlideModule public class LibraryModule extends BaseLibraryModule {} + """, + ) + val javaBaseAppModule = + JavaSourceFile( + "BaseAppModule.java", + """ + import com.bumptech.glide.module.AppGlideModule; + + public class BaseAppModule extends AppGlideModule { + public BaseAppModule() {} + } + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + + @GlideModule public class AppModule extends BaseAppModule { + public AppModule() {} + } + """, + ) + + compileCurrentSourceType( + kotlinAppModule, + kotlinLibraryModule, + javaBaseAppModule, + javaAppModule, + javaBaseLibraryModule, + javaLibraryModule, + ) { + assertThat(it.messages).doesNotContainMatch("[we]: \\[ksp] .*") + assertThat(it.exitCode).isEqualTo(ExitCode.OK) + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(appGlideModuleWithLibraryModule) + } + } + + @Test + fun compile_withMultipleLibraryGlideModules_andAppGlideModule_callsAllLibraryGlideModulesFromGeneratedAppGlideModule() { + val kotlinLibraryModule1 = + KotlinSourceFile( + "LibraryModule1.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class LibraryModule1 : LibraryGlideModule() + """, + ) + val kotlinLibraryModule2 = + KotlinSourceFile( + "LibraryModule2.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class LibraryModule2 : LibraryGlideModule() + """, + ) + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule class AppModule : AppGlideModule() + """, + ) + val javaLibraryModule1 = + JavaSourceFile( + "LibraryModule1.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.LibraryGlideModule; + + @GlideModule public class LibraryModule1 extends LibraryGlideModule {} + """, + ) + val javaLibraryModule2 = + JavaSourceFile( + "LibraryModule2.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.LibraryGlideModule; + + @GlideModule public class LibraryModule2 extends LibraryGlideModule {} + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule public class AppModule extends AppGlideModule { + public AppModule() {} + } + """, + ) + + compileCurrentSourceType( + kotlinAppModule, + kotlinLibraryModule1, + kotlinLibraryModule2, + javaAppModule, + javaLibraryModule1, + javaLibraryModule2, + ) { + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(appGlideModuleWithMultipleLibraryModules) + assertThat(it.exitCode).isEqualTo(ExitCode.OK) + } + } + + @Test + fun compile_withTheSameLibraryGlideModuleInMultipleFiles_andAnAppGlideModule_generatesGeneratedAppGlideModuleThatCallsTheLibraryGlideModuleOnce() { + // Kotlin seems fine with multiple identical classes. For Java this is compile time error + // already, so we don't have to handle it. + assumeTrue(sourceType == SourceType.KOTLIN) + val kotlinLibraryModule1 = + KotlinSourceFile( + "LibraryModule1.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class LibraryModule : LibraryGlideModule() + """, + ) + val kotlinLibraryModule2 = + KotlinSourceFile( + "LibraryModule2.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class LibraryModule : LibraryGlideModule() + """, + ) + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule class AppModule : AppGlideModule() + """, + ) + + compileCurrentSourceType(kotlinAppModule, kotlinLibraryModule1, kotlinLibraryModule2) { + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(appGlideModuleWithLibraryModule) + assertThat(it.exitCode).isEqualTo(ExitCode.OK) + assertThat(it.messages) + .contains( + GlideSymbolProcessorConstants.DUPLICATE_LIBRARY_MODULE_ERROR.format("[LibraryModule]") + ) + } + } + + @Test + fun compile_withLibraryGlideModulesWithDifferentPackages_butSameName_andAppGlideModule_callsEachLibraryGlideModuleOnceFromGeneratedAppGlideModule() { + // TODO(judds): The two java classes don't compile when run by the annotation processor, which + // means we can't really test this case for java code. Fix compilation issue and re-enable this + // test for Java code. + assumeTrue(sourceType == SourceType.KOTLIN) + val kotlinLibraryModule1 = + KotlinSourceFile( + "LibraryModule1.kt", + """ + package first_package + + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class LibraryModule : LibraryGlideModule() + """, + ) + val kotlinLibraryModule2 = + KotlinSourceFile( + "LibraryModule2.kt", + """ + package second_package + + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class LibraryModule : LibraryGlideModule() + """, + ) + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule class AppModule : AppGlideModule() + """, + ) + val javaLibraryModule1 = + JavaSourceFile( + "LibraryModule1.java", + """ + package first_package; + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.LibraryGlideModule; + + public class LibraryModule1 { + @GlideModule public static final class LibraryModule extends LibraryGlideModule {} + } + """, + ) + val javaLibraryModule2 = + JavaSourceFile( + "LibraryModule2.java", + """ + package second_package; + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.LibraryGlideModule; + + public class LibraryModule2 { + @GlideModule public static final class LibraryModule extends LibraryGlideModule {} + } + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule public class AppModule extends AppGlideModule { + public AppModule() {} + } + """, + ) + + compileCurrentSourceType( + kotlinAppModule, + kotlinLibraryModule1, + kotlinLibraryModule2, + javaAppModule, + javaLibraryModule1, + javaLibraryModule2, + ) { + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(appGlideModuleWithPackagePrefixedLibraryModules) + assertThat(it.exitCode).isEqualTo(ExitCode.OK) + } + } + + @Test + fun compile_withLibraryModuleInExcludes_producesGeneratedAppGlideModuleThatDoesNotCallExcludedLibraryModule() { + val kotlinLibraryModule1 = + KotlinSourceFile( + "LibraryModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class LibraryModule : LibraryGlideModule() + """, + ) + val kotlinLibraryModule2 = + KotlinSourceFile( + "ExcludedLibraryModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class ExcludedLibraryModule : LibraryGlideModule() + """, + ) + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.Excludes + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule + @Excludes(ExcludedLibraryModule::class) + class AppModule : AppGlideModule() + """, + ) + + val javaLibraryModule1 = + JavaSourceFile( + "LibraryModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.LibraryGlideModule; + + @GlideModule + public class LibraryModule extends LibraryGlideModule {} + """, + ) + val javaLibraryModule2 = + JavaSourceFile( + "ExcludedLibraryModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.LibraryGlideModule; + + @GlideModule + public class ExcludedLibraryModule extends LibraryGlideModule {} + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.Excludes; + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule + @Excludes(ExcludedLibraryModule.class) + public class AppModule extends AppGlideModule { + public AppModule() {} + } + """, + ) + compileCurrentSourceType( + kotlinAppModule, + kotlinLibraryModule1, + kotlinLibraryModule2, + javaAppModule, + javaLibraryModule1, + javaLibraryModule2, + ) { + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(appGlideModuleWithLibraryModule) + assertThat(it.exitCode).isEqualTo(ExitCode.OK) + } + } + + @Test + fun compile_withMultipleLibraryModulesInExcludes_producesGeneratedAppGlideModuleThatDoesNotCallExcludedLibraryModules() { + val kotlinLibraryModule1 = + KotlinSourceFile( + "LibraryModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class LibraryModule : LibraryGlideModule() + """, + ) + val kotlinLibraryModule2 = + KotlinSourceFile( + "ExcludedLibraryModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class ExcludedLibraryModule : LibraryGlideModule() + """, + ) + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.Excludes + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule + @Excludes(LibraryModule::class, ExcludedLibraryModule::class) + class AppModule : AppGlideModule() + """, + ) + + val javaLibraryModule1 = + JavaSourceFile( + "LibraryModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.LibraryGlideModule; + + @GlideModule + public class LibraryModule extends LibraryGlideModule {} + """, + ) + val javaLibraryModule2 = + JavaSourceFile( + "ExcludedLibraryModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.LibraryGlideModule; + + @GlideModule + public class ExcludedLibraryModule extends LibraryGlideModule {} + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.Excludes; + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule + @Excludes({LibraryModule.class, ExcludedLibraryModule.class}) + public class AppModule extends AppGlideModule { + public AppModule() {} + } + """, + ) + compileCurrentSourceType( + kotlinAppModule, + kotlinLibraryModule1, + kotlinLibraryModule2, + javaAppModule, + javaLibraryModule1, + javaLibraryModule2, + ) { + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(CommonSources.simpleAppGlideModule) + assertThat(it.exitCode).isEqualTo(ExitCode.OK) + } + } + + @Test + fun compile_withAppModuleWithEmptyExcludes_fails() { + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.Excludes + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule + @Excludes + class AppModule : AppGlideModule() + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.Excludes; + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule + @Excludes + public class AppModule extends AppGlideModule { + public AppModule() {} + } + """, + ) + compileCurrentSourceType(kotlinAppModule, javaAppModule) { + assertThat(it.exitCode).isEqualTo(ExitCode.COMPILATION_ERROR) + assertThat(it.messages) + .contains(AppGlideModuleConstants.INVALID_EXCLUDES_ANNOTATION_MESSAGE.format("AppModule")) + } + } + + @Test + fun compile_withAppModuleWithExcludes_pointingToAppModules_fails() { + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.Excludes + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + class SomeOtherAppModule: AppGlideModule() + + @GlideModule + @Excludes(SomeOtherAppModule::class) + class AppModule : AppGlideModule() + """, + ) + val otherJavaAppModule = + JavaSourceFile( + "SomeOtherAppModule.java", + """ + import com.bumptech.glide.module.AppGlideModule; + + public class SomeOtherAppModule extends AppGlideModule { + public SomeOtherAppModule() {} + } + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.Excludes; + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule + @Excludes(SomeOtherAppModule.class) + public class AppModule extends AppGlideModule { + public AppModule() {} + } + """, + ) + compileCurrentSourceType(kotlinAppModule, otherJavaAppModule, javaAppModule) { + assertThat(it.exitCode).isEqualTo(ExitCode.COMPILATION_ERROR) + assertThat(it.messages) + .contains(AppGlideModuleConstants.INVALID_EXCLUDES_ANNOTATION_MESSAGE.format("AppModule")) + } + } + + @Test + fun compile_withLibraryGlideModule_compiledSeparately_includesLibraryGlideModule_2() { + val kotlinLibraryModule = + KotlinSourceFile( + "LibraryModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.LibraryGlideModule + + @GlideModule class LibraryModule : LibraryGlideModule() + """, + ) + val javaLibraryModule = + JavaSourceFile( + "LibraryModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.LibraryGlideModule; + + @GlideModule public class LibraryModule extends LibraryGlideModule {} + """, + ) + + val libraryCompilationResult = compileCurrentSourceType(kotlinLibraryModule, javaLibraryModule) + + val kotlinAppModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule class AppModule : AppGlideModule() + """, + ) + val javaAppModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule public class AppModule extends AppGlideModule { + public AppModule() {} + } + """, + ) + + val generatedLibrarySources = + libraryCompilationResult.allGeneratedFiles().map { GeneratedSourceFile(it, sourceType) } + + compileCurrentSourceType( + *(listOf(kotlinAppModule, javaAppModule) + generatedLibrarySources).toTypedArray() + ) { + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(appGlideModuleWithLibraryModule) + } + } +} + +// generated code always includes public and Unit +@Suppress("RedundantVisibilityModifier", "RedundantUnitReturnType") +@Language("kotlin") +const val appGlideModuleWithPackagePrefixedLibraryModules = + """ +package com.bumptech.glide + +import AppModule +import android.content.Context +import first_package.LibraryModule +import kotlin.Boolean +import kotlin.Suppress +import kotlin.Unit + +internal class GeneratedAppGlideModuleImpl( + @Suppress("UNUSED_PARAMETER") + context: Context, +) : GeneratedAppGlideModule() { + private val appGlideModule: AppModule + init { + appGlideModule = AppModule() + } + + public override fun registerComponents( + context: Context, + glide: Glide, + registry: Registry, + ): Unit { + LibraryModule().registerComponents(context, glide, registry) + second_package.LibraryModule().registerComponents(context, glide, registry) + appGlideModule.registerComponents(context, glide, registry) + } + + public override fun applyOptions(context: Context, builder: GlideBuilder): Unit { + appGlideModule.applyOptions(context, builder) + } + + public override fun isManifestParsingEnabled(): Boolean = false +} +""" + +// generated code always includes public and Unit +@Suppress("RedundantVisibilityModifier", "RedundantUnitReturnType") +@Language("kotlin") +const val appGlideModuleWithLibraryModule = + """ +package com.bumptech.glide + +import AppModule +import LibraryModule +import android.content.Context +import kotlin.Boolean +import kotlin.Suppress +import kotlin.Unit + +internal class GeneratedAppGlideModuleImpl( + @Suppress("UNUSED_PARAMETER") + context: Context, +) : GeneratedAppGlideModule() { + private val appGlideModule: AppModule + init { + appGlideModule = AppModule() + } + + public override fun registerComponents( + context: Context, + glide: Glide, + registry: Registry, + ): Unit { + LibraryModule().registerComponents(context, glide, registry) + appGlideModule.registerComponents(context, glide, registry) + } + + public override fun applyOptions(context: Context, builder: GlideBuilder): Unit { + appGlideModule.applyOptions(context, builder) + } + + public override fun isManifestParsingEnabled(): Boolean = false +} +""" + +// generated code always includes public and Unit +@Suppress("RedundantVisibilityModifier", "RedundantUnitReturnType") +@Language("kotlin") +const val appGlideModuleWithMultipleLibraryModules = + """ +package com.bumptech.glide + +import AppModule +import LibraryModule1 +import LibraryModule2 +import android.content.Context +import kotlin.Boolean +import kotlin.Suppress +import kotlin.Unit + +internal class GeneratedAppGlideModuleImpl( + @Suppress("UNUSED_PARAMETER") + context: Context, +) : GeneratedAppGlideModule() { + private val appGlideModule: AppModule + init { + appGlideModule = AppModule() + } + + public override fun registerComponents( + context: Context, + glide: Glide, + registry: Registry, + ): Unit { + LibraryModule1().registerComponents(context, glide, registry) + LibraryModule2().registerComponents(context, glide, registry) + appGlideModule.registerComponents(context, glide, registry) + } + + public override fun applyOptions(context: Context, builder: GlideBuilder): Unit { + appGlideModule.applyOptions(context, builder) + } + + public override fun isManifestParsingEnabled(): Boolean = false +} +""" diff --git a/annotation/ksp/test/src/test/kotlin/com/bumptech/glide/annotation/ksp/test/ModuleSortingLogicTest.kt b/annotation/ksp/test/src/test/kotlin/com/bumptech/glide/annotation/ksp/test/ModuleSortingLogicTest.kt new file mode 100644 index 0000000000..c076f9ad03 --- /dev/null +++ b/annotation/ksp/test/src/test/kotlin/com/bumptech/glide/annotation/ksp/test/ModuleSortingLogicTest.kt @@ -0,0 +1,199 @@ +package com.bumptech.glide.annotation.ksp.test + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** + * Tests that module sorting by qualified name works correctly and deterministically. This ensures + * reproducible builds across KSP versions. + */ +@RunWith(JUnit4::class) +class ModuleSortingLogicTest { + + @Test + fun sortByQualifiedName_withTwoModules_sortsAlphabetically() { + val modules = listOf("com.test.ZebraModule", "com.test.AppleModule") + + val sorted = modules.sorted() + + assertThat(sorted).containsExactly("com.test.AppleModule", "com.test.ZebraModule").inOrder() + } + + @Test + fun sortByQualifiedName_withReverseOrder_producesIdenticalSortedResult() { + val modulesForward = listOf("com.test.Alpha", "com.test.Beta", "com.test.Gamma") + val modulesReverse = listOf("com.test.Gamma", "com.test.Beta", "com.test.Alpha") + + val sortedForward = modulesForward.sorted() + val sortedReverse = modulesReverse.sorted() + + assertThat(sortedForward).isEqualTo(sortedReverse) + assertThat(sortedForward) + .containsExactly("com.test.Alpha", "com.test.Beta", "com.test.Gamma") + .inOrder() + } + + @Test + fun sortByQualifiedName_withDifferentPackages_sortsByFullQualifiedName() { + val modules = + listOf("org.example.Module", "com.example.Module", "app.example.Module", "net.example.Module") + + val sorted = modules.sorted() + + assertThat(sorted) + .containsExactly( + "app.example.Module", + "com.example.Module", + "net.example.Module", + "org.example.Module", + ) + .inOrder() + } + + @Test + fun sortByQualifiedName_withSimilarNames_sortsCorrectly() { + val modules = listOf("MyModule", "MyModuleExt", "MyModule2", "MyModuleA") + + val sorted = modules.sorted() + + assertThat(sorted) + .containsExactly("MyModule", "MyModule2", "MyModuleA", "MyModuleExt") + .inOrder() + } + + @Test + fun sortByQualifiedName_withNestedPackages_sortsByDepth() { + val modules = + listOf("com.test.deep.nested.DeepModule", "com.test.ShallowModule", "com.test.deep.MidModule") + + val sorted = modules.sorted() + + assertThat(sorted) + .containsExactly( + "com.test.ShallowModule", + "com.test.deep.MidModule", + "com.test.deep.nested.DeepModule", + ) + .inOrder() + } + + @Test + fun sortByQualifiedName_withTenModules_sortsCorrectly() { + val modules = + listOf( + "Module10", + "Module1", + "Module9", + "Module2", + "Module8", + "Module3", + "Module7", + "Module4", + "Module6", + "Module5", + ) + + val sorted = modules.sorted() + + assertThat(sorted) + .containsExactly( + "Module1", + "Module10", + "Module2", + "Module3", + "Module4", + "Module5", + "Module6", + "Module7", + "Module8", + "Module9", + ) + .inOrder() + } + + @Test + fun sortByQualifiedName_withRandomOrder_alwaysProducesSameResult() { + val moduleNames = + listOf("Delta", "Alpha", "Echo", "Bravo", "Charlie", "Foxtrot", "Golf", "Hotel", "India") + + // Create three different orderings + val modules1 = moduleNames + val modules2 = moduleNames.shuffled() + val modules3 = moduleNames.shuffled() + + val sorted1 = modules1.sorted() + val sorted2 = modules2.sorted() + val sorted3 = modules3.sorted() + + // All three should produce identical sorted results + assertThat(sorted1).isEqualTo(sorted2) + assertThat(sorted2).isEqualTo(sorted3) + assertThat(sorted1) + .containsExactly( + "Alpha", + "Bravo", + "Charlie", + "Delta", + "Echo", + "Foxtrot", + "Golf", + "Hotel", + "India", + ) + .inOrder() + } + + @Test + fun sortByQualifiedName_withCaseSensitiveNames_sortsCorrectly() { + val modules = listOf("com.test.aModule", "com.test.AModule", "com.test.BModule") + + val sorted = modules.sorted() + + // Capital letters come before lowercase in lexicographic order + assertThat(sorted) + .containsExactly("com.test.AModule", "com.test.BModule", "com.test.aModule") + .inOrder() + } + + @Test + fun sortByQualifiedName_withLongPackageHierarchy_sortsCorrectly() { + val modules = + listOf( + "com.company.product.feature.module.deep.nested.VeryDeepModule", + "com.company.product.feature.ShallowModule", + "com.company.product.feature.module.MidModule", + "com.company.OtherModule", + ) + + val sorted = modules.sorted() + + assertThat(sorted) + .containsExactly( + "com.company.OtherModule", + "com.company.product.feature.ShallowModule", + "com.company.product.feature.module.MidModule", + "com.company.product.feature.module.deep.nested.VeryDeepModule", + ) + .inOrder() + } + + @Test + fun sortByQualifiedName_withSpecialCharacters_sortsCorrectly() { + // Test modules with underscores and numbers + val modules = + listOf("com.test.Module_V2", "com.test.Module_V1", "com.test.Module2", "com.test.Module1") + + val sorted = modules.sorted() + + assertThat(sorted) + .containsExactly( + "com.test.Module1", + "com.test.Module2", + "com.test.Module_V1", + "com.test.Module_V2", + ) + .inOrder() + } +} diff --git a/annotation/ksp/test/src/test/kotlin/com/bumptech/glide/annotation/ksp/test/OnlyAppGlideModuleTests.kt b/annotation/ksp/test/src/test/kotlin/com/bumptech/glide/annotation/ksp/test/OnlyAppGlideModuleTests.kt new file mode 100644 index 0000000000..124bf39df4 --- /dev/null +++ b/annotation/ksp/test/src/test/kotlin/com/bumptech/glide/annotation/ksp/test/OnlyAppGlideModuleTests.kt @@ -0,0 +1,353 @@ +package com.bumptech.glide.annotation.ksp.test + +import com.bumptech.glide.annotation.ksp.AppGlideModuleConstants +import com.bumptech.glide.annotation.ksp.GlideSymbolProcessorConstants +import com.google.common.truth.Truth.assertThat +import com.tschuchort.compiletesting.KotlinCompilation +import org.intellij.lang.annotations.Language +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +@OptIn(ExperimentalCompilerApi::class) +class OnlyAppGlideModuleTests(override val sourceType: SourceType) : PerSourceTypeTest { + + companion object { + @Parameterized.Parameters(name = "sourceType = {0}") @JvmStatic fun data() = SourceType.values() + } + + @Test + fun compile_withGlideModuleOnNonLibraryClass_fails() { + val kotlinSource = + KotlinSourceFile( + "Something.kt", + """ + import com.bumptech.glide.annotation.GlideModule + @GlideModule class Something + """ + ) + + val javaSource = + JavaSourceFile( + "Something.java", + """ + package test; + + import com.bumptech.glide.annotation.GlideModule; + @GlideModule + public class Something {} + """ + ) + + compileCurrentSourceType(kotlinSource, javaSource) { + assertThat(it.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(it.messages) + .containsMatch( + GlideSymbolProcessorConstants.INVALID_ANNOTATED_CLASS.format(".*/Something.*") + ) + } + } + + @Test + fun compile_withGlideModuleOnValidAppGlideModule_generatedGeneratedAppGlideModule() { + val kotlinModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule class AppModule : AppGlideModule() + """ + ) + val javaModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule public class AppModule extends AppGlideModule {} + """ + .trimIndent() + ) + + compileCurrentSourceType(kotlinModule, javaModule) { + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(CommonSources.simpleAppGlideModule) + assertThat(it.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + } + } + + @Test + fun compile_withGlideModuleOnValidAppGlideModuleThroughBaseClass_generatedGeneratedAppGlideModule() { + val kotlinModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + class BaseAppModule : AppGlideModule() + @GlideModule class AppModule : BaseAppModule() + """ + ) + val javaBaseAppModule = + JavaSourceFile( + "BaseAppModule.java", + """ + import com.bumptech.glide.module.AppGlideModule; + + public class BaseAppModule extends AppGlideModule {} + """ + .trimIndent() + ) + val javaModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + + @GlideModule public class AppModule extends BaseAppModule {} + """ + .trimIndent() + ) + + compileCurrentSourceType(kotlinModule, javaBaseAppModule, javaModule) { + assertThat(it.generatedAppGlideModuleContents()) + .hasSourceEqualTo(CommonSources.simpleAppGlideModule) + assertThat(it.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + } + } + + @Test + fun compile_withAppGlideModuleConstructorAcceptingOnlyContext_generatesGeneratedAppGlideModule() { + val kotlinModule = + KotlinSourceFile( + "AppModule.kt", + """ + import android.content.Context + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule class AppModule(context: Context) : AppGlideModule() + """ + ) + + val javaModule = + JavaSourceFile( + "AppModule.java", + """ + import android.content.Context; + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule public class AppModule extends AppGlideModule { + public AppModule(Context context) {} + } + """ + ) + + compileCurrentSourceType(kotlinModule, javaModule) { + assertThat(it.generatedAppGlideModuleContents()).hasSourceEqualTo(appGlideModuleWithContext) + assertThat(it.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + } + } + + @Test + fun compile_withAppGlideModuleConstructorRequiringOtherThanContext_fails() { + val kotlinModule = + KotlinSourceFile( + "AppModule.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule class AppModule(value: Int) : AppGlideModule() + """ + ) + val javaModule = + JavaSourceFile( + "AppModule.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule public class AppModule extends AppGlideModule { + public AppModule(Integer value) {} + } + """ + ) + + compileCurrentSourceType(kotlinModule, javaModule) { + assertThat(it.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(it.messages).contains(AppGlideModuleConstants.INVALID_MODULE_MESSAGE) + } + } + + @Test + fun compile_withAppGlideModuleConstructorRequiringMultipleArguments_fails() { + val kotlinModule = + KotlinSourceFile( + "AppModule.kt", + """ + import android.content.Context + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule class AppModule(value: Context, otherValue: Int) : AppGlideModule() + """ + ) + val javaModule = + JavaSourceFile( + "AppModule.java", + """ + import android.content.Context; + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule public class AppModule extends AppGlideModule { + public AppModule(Context value, int otherValue) {} + } + """ + ) + + compileCurrentSourceType(kotlinModule, javaModule) { + assertThat(it.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(it.messages).contains(AppGlideModuleConstants.INVALID_MODULE_MESSAGE) + } + } + + // This is quite weird, we could probably pretty reasonably just assert that this doesn't happen. + @Test + fun compile_withAppGlideModuleWithOneEmptyConstructor_andOneContextOnlyConstructor_usesTheContextOnlyConstructor() { + val kotlinModule = + KotlinSourceFile( + "AppModule.kt", + """ + import android.content.Context + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule class AppModule(context: Context?) : AppGlideModule() { + constructor() : this(null) + } + + """ + ) + val javaModule = + JavaSourceFile( + "AppModule.java", + """ + import android.content.Context; + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + import javax.annotation.Nullable; + + @GlideModule public class AppModule extends AppGlideModule { + public AppModule() {} + public AppModule(@Nullable Context context) {} + } + """ + ) + + compileCurrentSourceType(kotlinModule, javaModule) { + assertThat(it.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + assertThat(it.generatedAppGlideModuleContents()).hasSourceEqualTo(appGlideModuleWithContext) + } + } + + @Test + fun compile_withMultipleAppGlideModules_fails() { + val firstKtModule = + KotlinSourceFile( + "Module1.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule class Module1 : AppGlideModule() + """ + ) + + val secondKtModule = + KotlinSourceFile( + "Module2.kt", + """ + import com.bumptech.glide.annotation.GlideModule + import com.bumptech.glide.module.AppGlideModule + + @GlideModule class Module2 : AppGlideModule() + """ + ) + + val firstJavaModule = + JavaSourceFile( + "Module1.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule public class Module1 extends AppGlideModule { + public Module1() {} + } + """ + ) + + val secondJavaModule = + JavaSourceFile( + "Module2.java", + """ + import com.bumptech.glide.annotation.GlideModule; + import com.bumptech.glide.module.AppGlideModule; + + @GlideModule public class Module2 extends AppGlideModule { + public Module2() {} + } + """ + ) + + compileCurrentSourceType(firstKtModule, secondKtModule, firstJavaModule, secondJavaModule) { + assertThat(it.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(it.messages) + .contains( + GlideSymbolProcessorConstants.SINGLE_APP_MODULE_ERROR.format("[Module1, Module2]") + ) + } + } +} + +@Language("kotlin") +const val appGlideModuleWithContext = + """ +package com.bumptech.glide + +import AppModule +import android.content.Context +import kotlin.Boolean +import kotlin.Unit + +internal class GeneratedAppGlideModuleImpl( + context: Context, +) : GeneratedAppGlideModule() { + private val appGlideModule: AppModule + init { + appGlideModule = AppModule(context) + } + + public override fun registerComponents( + context: Context, + glide: Glide, + registry: Registry, + ): Unit { + appGlideModule.registerComponents(context, glide, registry) + } + + public override fun applyOptions(context: Context, builder: GlideBuilder): Unit { + appGlideModule.applyOptions(context, builder) + } + + public override fun isManifestParsingEnabled(): Boolean = false +} +""" diff --git a/annotation/src/main/java/com/bumptech/glide/annotation/GlideModule.java b/annotation/src/main/java/com/bumptech/glide/annotation/GlideModule.java index b6fdb2942b..d2ccd6bcdc 100644 --- a/annotation/src/main/java/com/bumptech/glide/annotation/GlideModule.java +++ b/annotation/src/main/java/com/bumptech/glide/annotation/GlideModule.java @@ -9,7 +9,7 @@ * Identifies AppGlideModules and LibraryGlideModules for Glide's annotation processor to merge at * compile time. * - *

Replaces tags in AndroidManifest.xml. + *

Replaces {@code } tags in AndroidManifest.xml. */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.CLASS) diff --git a/annotation/src/main/java/com/bumptech/glide/annotation/GlideOption.java b/annotation/src/main/java/com/bumptech/glide/annotation/GlideOption.java index c2f3c32296..18c69c4708 100644 --- a/annotation/src/main/java/com/bumptech/glide/annotation/GlideOption.java +++ b/annotation/src/main/java/com/bumptech/glide/annotation/GlideOption.java @@ -27,7 +27,7 @@ * be avoided. The preferred style looks like: * *

{@code
- * {@link @}GlideExtension
+ * {@literal @}GlideExtension
  * public class MyExtension {
  *   private MyExtension() {}
  *
@@ -68,8 +68,10 @@
 public @interface GlideOption {
   /** Does not intend to override a method in a super class. */
   int OVERRIDE_NONE = 0;
+
   /** Expects to call super and then add additional functionality to an overridden method. */
   int OVERRIDE_EXTEND = 1;
+
   /** Expects to not call super and replace an overridden method. */
   int OVERRIDE_REPLACE = 2;
 
diff --git a/annotation/src/main/java/com/bumptech/glide/annotation/ksp/Index.java b/annotation/src/main/java/com/bumptech/glide/annotation/ksp/Index.java
new file mode 100644
index 0000000000..44222755ec
--- /dev/null
+++ b/annotation/src/main/java/com/bumptech/glide/annotation/ksp/Index.java
@@ -0,0 +1,19 @@
+package com.bumptech.glide.annotation.ksp;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Used to retrieve LibraryGlideModule and GlideExtension classes in our annotation processor from
+ * libraries and applications.
+ *
+ * 

Part of the internals of Glide's annotation processor and not for public use. + */ +@Target(ElementType.TYPE) +// Needs to be parsed from class files in JAR. +@Retention(RetentionPolicy.CLASS) +@interface Index { + String[] modules() default {}; +} diff --git a/benchmark/build.gradle b/benchmark/build.gradle deleted file mode 100644 index ba8ad3f5cd..0000000000 --- a/benchmark/build.gradle +++ /dev/null @@ -1,42 +0,0 @@ -plugins { - id 'com.android.library' - id 'androidx.benchmark' -} - -android { - compileSdkVersion 30 - buildToolsVersion "30.0.3" - - compileOptions { - sourceCompatibility = 1.7 - targetCompatibility = 1.7 - } - - defaultConfig { - minSdkVersion 19 - targetSdkVersion 30 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner 'androidx.benchmark.junit4.AndroidBenchmarkRunner' - } - - buildTypes { - debug { - // Since debuggable can"t be modified by gradle for library modules, - // it must be done in a manifest - see src/androidTest/AndroidManifest.xml - minifyEnabled true - proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "benchmark-proguard-rules.pro" - } - } -} - -dependencies { - androidTestImplementation "androidx.test:runner:${ANDROID_X_TEST_VERSION}" - androidTestImplementation "androidx.test.ext:junit:${ANDROID_X_TEST_VERSION}" - androidTestImplementation "junit:junit:{$JUNIT_VERSION}" - - androidTestImplementation "androidx.benchmark:benchmark-junit4:${ANDROID_X_BENCHMARK_VERSION}" - androidTestImplementation project(':library') - androidTestImplementation "com.google.guava:guava:${GUAVA_VERSION}" -} \ No newline at end of file diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts new file mode 100644 index 0000000000..2f51ee812e --- /dev/null +++ b/benchmark/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.library") + id("androidx.benchmark") +} + +android { + namespace = "com.bumptech.glide.benchmark" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + + testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner" + multiDexEnabled = true + } + + buildTypes { + getByName("debug") { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "benchmark-proguard-rules.pro", + ) + } + } +} + +dependencies { + implementation(libs.androidx.multidex) + + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.junit) + + androidTestImplementation(project(":library")) + androidTestImplementation(project(":testutil")) + androidTestImplementation(libs.androidx.benchmark.junit) + androidTestImplementation(libs.guava) +} diff --git a/benchmark/src/androidTest/AndroidManifest.xml b/benchmark/src/androidTest/AndroidManifest.xml index f0a99419ef..7795cfbe2e 100644 --- a/benchmark/src/androidTest/AndroidManifest.xml +++ b/benchmark/src/androidTest/AndroidManifest.xml @@ -1,7 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> @@ -71,11 +73,6 @@ - - - - - diff --git a/checkstyle_suppressions.xml b/checkstyle_suppressions.xml index c01d11dc3b..89953f67de 100644 --- a/checkstyle_suppressions.xml +++ b/checkstyle_suppressions.xml @@ -10,6 +10,10 @@ + + + + diff --git a/exifsamples/Landscape_0.jpg b/exifsamples/Landscape_0.jpg new file mode 100644 index 0000000000..0da16ebb58 Binary files /dev/null and b/exifsamples/Landscape_0.jpg differ diff --git a/exifsamples/Landscape_1.jpg b/exifsamples/Landscape_1.jpg new file mode 100644 index 0000000000..d6eda8dd99 Binary files /dev/null and b/exifsamples/Landscape_1.jpg differ diff --git a/exifsamples/Landscape_2.jpg b/exifsamples/Landscape_2.jpg new file mode 100644 index 0000000000..10369e8b98 Binary files /dev/null and b/exifsamples/Landscape_2.jpg differ diff --git a/exifsamples/Landscape_3.jpg b/exifsamples/Landscape_3.jpg new file mode 100644 index 0000000000..842b827f3a Binary files /dev/null and b/exifsamples/Landscape_3.jpg differ diff --git a/exifsamples/Landscape_4.jpg b/exifsamples/Landscape_4.jpg new file mode 100644 index 0000000000..c58d01d247 Binary files /dev/null and b/exifsamples/Landscape_4.jpg differ diff --git a/exifsamples/Landscape_5.jpg b/exifsamples/Landscape_5.jpg new file mode 100644 index 0000000000..1828accb9a Binary files /dev/null and b/exifsamples/Landscape_5.jpg differ diff --git a/exifsamples/Landscape_6.jpg b/exifsamples/Landscape_6.jpg new file mode 100644 index 0000000000..1bfa104789 Binary files /dev/null and b/exifsamples/Landscape_6.jpg differ diff --git a/exifsamples/Landscape_7.jpg b/exifsamples/Landscape_7.jpg new file mode 100644 index 0000000000..4604536d5c Binary files /dev/null and b/exifsamples/Landscape_7.jpg differ diff --git a/exifsamples/Landscape_8.jpg b/exifsamples/Landscape_8.jpg new file mode 100644 index 0000000000..bdb72e57c4 Binary files /dev/null and b/exifsamples/Landscape_8.jpg differ diff --git a/exifsamples/Portrait_0.jpg b/exifsamples/Portrait_0.jpg new file mode 100644 index 0000000000..55e3d3d9b6 Binary files /dev/null and b/exifsamples/Portrait_0.jpg differ diff --git a/exifsamples/Portrait_1.jpg b/exifsamples/Portrait_1.jpg new file mode 100644 index 0000000000..9e2265ea64 Binary files /dev/null and b/exifsamples/Portrait_1.jpg differ diff --git a/exifsamples/Portrait_2.jpg b/exifsamples/Portrait_2.jpg new file mode 100644 index 0000000000..e6659cf236 Binary files /dev/null and b/exifsamples/Portrait_2.jpg differ diff --git a/exifsamples/Portrait_3.jpg b/exifsamples/Portrait_3.jpg new file mode 100644 index 0000000000..b6677a0bc4 Binary files /dev/null and b/exifsamples/Portrait_3.jpg differ diff --git a/exifsamples/Portrait_4.jpg b/exifsamples/Portrait_4.jpg new file mode 100644 index 0000000000..a30ebcd7c9 Binary files /dev/null and b/exifsamples/Portrait_4.jpg differ diff --git a/exifsamples/Portrait_5.jpg b/exifsamples/Portrait_5.jpg new file mode 100644 index 0000000000..81903adbf3 Binary files /dev/null and b/exifsamples/Portrait_5.jpg differ diff --git a/exifsamples/Portrait_6.jpg b/exifsamples/Portrait_6.jpg new file mode 100644 index 0000000000..aa208a8c84 Binary files /dev/null and b/exifsamples/Portrait_6.jpg differ diff --git a/exifsamples/Portrait_7.jpg b/exifsamples/Portrait_7.jpg new file mode 100644 index 0000000000..59336f3802 Binary files /dev/null and b/exifsamples/Portrait_7.jpg differ diff --git a/exifsamples/Portrait_8.jpg b/exifsamples/Portrait_8.jpg new file mode 100644 index 0000000000..de16d061b0 Binary files /dev/null and b/exifsamples/Portrait_8.jpg differ diff --git a/glide/build.gradle b/glide/build.gradle index 0d8381cb02..81502d3132 100644 --- a/glide/build.gradle +++ b/glide/build.gradle @@ -1,5 +1,18 @@ import com.android.build.gradle.api.LibraryVariant +/** + * This module is used for two things: + *

    + *
  • Compiling a single unified set of javadocs for Glide + *
  • Providing a jar version of Glide for internal libraries, like + * Glide's annotation processor. + *
+ * + *

Previously this module was used to produce a release jar for Glide, but + * we've long since stopped releasing the jar. Now all release artifacts come + * from the upload script, which uploads aars for each production submodule + */ + apply plugin: 'java' // The paths of Android projects that should be included only in Javadoc, not in the jar. @@ -18,6 +31,10 @@ static def getAndroidPathsForJavadoc() { ] } +static def getAndroidPathsForJar() { + [':library', ':third_party:disklrucache', ':third_party:gif_decoder'] +} + // The paths of Java projects that should be included only in Javadoc, not in the jar. static def getJavaPathsForJavadoc() { [':annotation'] @@ -32,7 +49,7 @@ def asProjects(paths) { } def getAndroidSdkDirectory() { - project(':library').android.sdkDirectory + project(':library').androidComponents.sdkComponents.sdkDirectory.get().asFile.absolutePath } def getAndroidCompileSdkVersion() { @@ -43,13 +60,7 @@ def getAndroidProjectsForJavadoc() { asProjects(getAndroidPathsForJavadoc()) } -def getAndroidLibraryVariantsForJavadoc() { - getAndroidProjectsForJavadoc().collect { project -> - project.android.libraryVariants.findAll { type -> - type.buildType.name.equalsIgnoreCase("debug") - } - }.sum() -} + def getSourceFilesForJavadoc() { getAndroidProjectsForJavadoc().collect { project -> @@ -61,46 +72,44 @@ def getAndroidJar() { "${getAndroidSdkDirectory()}/platforms/${getAndroidCompileSdkVersion()}/android.jar" } -project.archivesBaseName = "${POM_ARTIFACT_ID}-${VERSION_NAME}" +base { + archivesName = "${POM_ARTIFACT_ID}-${VERSION_NAME}" +} // Generate javadocs and sources containing batched documentation and sources for all internal // projects. -def javadocTask = tasks.create("debugJavadoc", Javadoc) { +def javadocTask = tasks.create("releaseJavadoc", Javadoc) { source = getSourceFilesForJavadoc() doFirst { it.classpath = project.files( getAndroidJar(), - getAndroidLibraryVariantsForJavadoc().collect { - LibraryVariant lib -> - lib.getJavaCompileProvider().get().classpath.files + getAndroidProjectsForJavadoc().collect { Project proj -> + proj.tasks.compileReleaseJavaWithJavac.classpath }, // Finds dependencies of Android packages that would otherwise be // ignored (Volley in particular) getAndroidProjectsForJavadoc().collect { Project project -> - project.file('build/intermediates/classes/debug') + project.file('build/intermediates/javac/release/classes') } ) } options { - links("http://docs.oracle.com/javase/7/docs/api/") - links("https://square.github.io/okhttp/3.x/okhttp/") - links("https://square.github.io/okhttp/2.x/okhttp/") - linksOffline("http://d.android.com/reference", - "${getAndroidSdkDirectory()}/docs/reference") + links("https://docs.oracle.com/javase/7/docs/api/") + links("https://developer.android.com/reference") } exclude '**/R.java' } -def cleanJavadocTask = task("cleanDebugJavadoc", type: Delete) { +def cleanJavadocTask = task("cleanReleaseJavadoc", type: Delete) { delete javadocTask.destinationDir } as Task clean.dependsOn(cleanJavadocTask) -def javadocJarTask = task("debugJavadocJar", type: Jar) { +def javadocJarTask = task("releaseJavadocJar", type: Jar) { from javadocTask.destinationDir } as Task @@ -108,15 +117,15 @@ javadocJarTask.dependsOn(javadocTask) (getAndroidProjectsForJavadoc()).each { project -> - debugJavadoc.dependsOn(project.tasks.compileDebugSources) - jar.dependsOn(project.tasks.compileDebugSources) + releaseJavadoc.dependsOn(project.tasks.compileReleaseSources) + jar.dependsOn(project.tasks.compileReleaseSources) } jar { from files( - getAndroidLibraryVariantsForJavadoc().collect { LibraryVariant variant -> - variant.getJavaCompileProvider().get().destinationDir + asProjects(getAndroidPathsForJar()).collect { Project proj -> + proj.tasks.compileReleaseJavaWithJavac.destinationDirectory } ) exclude "**/R.class" @@ -126,8 +135,8 @@ jar { } artifacts { - archives debugJavadocJar { - classifier 'javadoc' + archives releaseJavadocJar { + archiveClassifier = 'javadoc' } } diff --git a/gradle.properties b/gradle.properties index e9cea63ded..28a3505f84 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,34 +10,12 @@ # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -#Wed Mar 17 09:49:19 PDT 2021 -ANDROID_GRADLE_VERSION=4.1.0 -ANDROID_SUPPORT_VERSION=27.1.1 -ANDROID_X_TEST_VERSION=1.1.0 -ANDROID_X_TEST_CORE_VERSION=1.3.0 -ANDROID_X_FUTURES_VERSION=1.1.0 -ANDROID_X_FRAGMENT_VERSION=1.3.1 -ANDROID_X_VERSION=1.0.0 -ANDROID_X_ANNOTATION_VERSION=1.1.0 -ANDROID_X_BENCHMARK_VERSION=1.0.0 -AUTO_SERVICE_VERSION=1.0-rc3 -COMPILE_SDK_VERSION=29 -DAGGER_VERSION=2.15 -ERROR_PRONE_PLUGIN_VERSION=0.0.13 -ERROR_PRONE_VERSION=2.3.1 -EXIF_INTERFACE_VERSION=1.2.0 -FINDBUGS_VERSION=3.0.0 +#Sun Jun 05 16:53:18 EST 2022 + +## Grouping GROUP=com.github.bumptech.glide -GUAVA_VERSION=28.1-android -JAVAPOET_VERSION=1.9.0 -JSR_305_VERSION=3.0.2 -JUNIT_VERSION=4.13.2 -MIN_SDK_VERSION=14 -MOCKITO_ANDROID_VERSION=2.24.0 -MOCKITO_VERSION=2.24.0 -MOCKWEBSERVER_VERSION=3.0.0-RC1 -OK_HTTP_VERSION=3.10.0 -PMD_VERSION=6.0.0 + +## Metadata POM_DESCRIPTION=A fast and efficient image loading library for Android focused on smooth scrolling. POM_DEVELOPER_EMAIL=judds@google.com POM_DEVELOPER_ID=sjudd @@ -46,18 +24,18 @@ POM_SCM_CONNECTION=scm\:git@github.com\:bumptech/glide.git POM_SCM_DEV_CONNECTION=scm\:git@github.com\:bumptech/glide.git POM_SCM_URL=https\://github.com/bumptech/glide POM_URL=https\://github.com/bumptech/glide -ROBOLECTRIC_VERSION=4.3.1 -TARGET_SDK_VERSION=28 -TEST_JVM_MEMORY_SIZE=4096M -TRUTH_VERSION=0.45 -VERSION_MAJOR=4 -VERSION_MINOR=13 -VERSION_NAME=4.13.0-SNAPSHOT -VERSION_PATCH=0 -VIOLATIONS_PLUGIN_VERSION=1.8 -VOLLEY_VERSION=1.2.0 -android.enableJetifier=true + +## Gradle config android.useAndroidX=true org.gradle.configureondemand=false org.gradle.daemon=true org.gradle.jvmargs=-Xmx4096M +TEST_JVM_MEMORY_SIZE=4096M + +## Glide versioning - these may be overwritten in lower level gradle.properties files +VERSION_MAJOR=5 +VERSION_MINOR=0 +VERSION_PATCH=5 +VERSION_NAME=5.0.5 + +android.disallowKotlinSourceSets=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000000..1c1c6029de --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,153 @@ +[versions] +avif = "1.1.1.14d8e3c4" +gson = "2.8.2" +pmd = "6.0.0" +dagger = "2.60.1" +compose = "1.5.1" +kotlin = "2.0.20" +mockito = "5.3.1" +retrofit = "2.3.0" +coroutines = "1.8.0" +ksp = "2.0.20-1.0.25" +errorprone = "2.36.0" +min-sdk-version = "23" +target-sdk-version = "32" +androidx-espresso = "3.5.1" +androidx-fragment = "1.6.1" +okhttp-min-sdk-version = "21" +kotlin-compiler-extension = "1.5.5" +androidx-benchmark = "1.5.0-rc01" +compile-sdk-version = "37" +androidx-multidex = "2.0.1" +autoservice = "1.0-rc3" +autoservice-annotations = "1.0.1" +android-gradle = "9.2.0" +androidx-cardview = "1.0.0" +androidx-core = "1.12.0" +androidx-annotation = "1.7.1" +androidx-appcompat = "1.8.0" +androidx-exifinterface = "1.3.6" +androidx-futures = "1.1.0" +androidx-junit = "1.1.5" +androidx-lifecycle-runtime = "2.8.2" +androidx-recyclerview = "1.3.1" +androidx-test-core = "1.4.0" +androidx-test-ktx = "1.5.0" +androidx-test-rules = "1.4.0" +androidx-test-runner = "1.4.0" +androidx-tracing = "1.0.0" +androidx-vectordrawable = "1.1.0" +proguard-gradle = "7.1.0" +coroutines-binarycompat-gradle = "0.18.1" +cronet = "17.0.1" +dokka-gradle = "1.8.20" +drawablepainter = "0.25.1" +errorprone-gradle = "4.1.0" +findbugs-jsr305 = "3.0.2" +guava = "28.1-android" +guava-testlib = "18.0" +javapoet = "1.9.0" +junit = "4.13.2" +kotlinpoet = "1.12.0" +ksp-autoservice = "1.0.0" +ksp-compiletesting = "1.6.0" +mockwebserver = "3.0.0-RC1" +okhttp2 = "2.7.5" +okhttp3 = "3.10.0" +okhttp4 = "4.10.0" +robolectric = "4.16.1" +rx-android = "1.2.1" +rx-java = "1.3.8" +svg = "1.2.1" +truth = "1.4.5" +violations = "1.8" +volley = "1.2.1" +vanniktech = "0.34.0" +ktfmt = "0.25.0" + +[libraries] +androidx-multidex = { group = "androidx.multidex", name = "multidex", version.ref = "androidx-multidex" } +autoservice = { group = "com.google.auto.service", name = "auto-service", version.ref = "autoservice" } +autoservice-annotations = { group = "com.google.auto.service", name = "auto-service-annotations", version.ref = "autoservice-annotations" } +android-gradle = { group = "com.android.tools.build", name = "gradle", version.ref = "android-gradle" } +androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "androidx-cardview" } +androidx-core = { group = "androidx.core", name = "core", version.ref = "androidx-core" } +androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "androidx-annotation" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" } +androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "androidx-exifinterface" } +androidx-futures = { group = "androidx.concurrent", name = "concurrent-futures", version.ref = "androidx-futures" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime" } +androidx-lifecycle-runtime-testing = { group = "androidx.lifecycle", name = "lifecycle-runtime-testing", version.ref = "androidx-lifecycle-runtime" } +androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "androidx-recyclerview" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidx-test-core" } +androidx-test-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "androidx-test-ktx" } +androidx-test-ktx-junit = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidx-junit" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidx-test-rules" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" } +androidx-tracing = { group = "androidx.tracing", name = "tracing", version.ref = "androidx-tracing" } +androidx-vectordrawable = { group = "androidx.vectordrawable", name = "vectordrawable-animated", version.ref = "androidx-vectordrawable" } +avif = { module = "org.aomedia.avif.android:avif", version.ref = "avif" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +proguard-gradle = { group = "com.guardsquare", name = "proguard-gradle", version.ref = "proguard-gradle" } +compose-material = { group = "androidx.compose.material", name = "material", version.ref = "compose" } +coroutines-binarycompat-gradle = { group = "org.jetbrains.kotlinx", name = "binary-compatibility-validator", version.ref = "coroutines-binarycompat-gradle" } +cronet = { group = "com.google.android.gms", name = "play-services-cronet", version.ref = "cronet" } +dokka-gradle = { group = "org.jetbrains.dokka", name = "dokka-gradle-plugin", version.ref = "dokka-gradle" } +drawablepainter = { group = "com.google.accompanist", name = "accompanist-drawablepainter", version.ref = "drawablepainter" } +errorprone-gradle = { group = "net.ltgt.gradle", name = "gradle-errorprone-plugin", version.ref = "errorprone-gradle" } +findbugs-jsr305 = { group = "com.google.code.findbugs", name = "jsr305", version.ref = "findbugs-jsr305" } +guava = { group = "com.google.guava", name = "guava", version.ref = "guava" } +guava-testlib = { group = "com.google.guava", name = "guava-testlib", version.ref = "guava-testlib" } +javapoet = { group = "com.squareup", name = "javapoet", version.ref = "javapoet" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +kotlinpoet = { group = "com.squareup", name = "kotlinpoet", version.ref = "kotlinpoet" } +ksp-autoservice = { group = "dev.zacsweers.autoservice", name = "auto-service-ksp", version.ref = "ksp-autoservice" } +ksp-compiletesting = { group = "com.github.tschuchortdev", name = "kotlin-compile-testing-ksp", version.ref = "ksp-compiletesting" } +mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockwebserver" } +okhttp2 = { group = "com.squareup.okhttp", name = "okhttp", version.ref = "okhttp2" } +okhttp3 = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp3" } +okhttp4 = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp4" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +rx-android = { group = "io.reactivex", name = "rxandroid", version.ref = "rx-android" } +rx-java = { group = "io.reactivex", name = "rxjava", version.ref = "rx-java" } +svg = { group = "com.caverock", name = "androidsvg", version.ref = "svg" } +truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } +violations = { group = "se.bjurr.violations", name = "violations-gradle-plugin", version.ref = "violations" } +volley = { group = "com.android.volley", name = "volley", version.ref = "volley" } +vanniktech = { group = "com.vanniktech", name = "gradle-maven-publish-plugin", version.ref = "vanniktech" } +androidx-benchmark-gradle = { group = "androidx.benchmark", name = "benchmark-gradle-plugin", version.ref = "androidx-benchmark" } +androidx-benchmark-junit = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "androidx-benchmark" } +androidx-espresso = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso" } +androidx-espresso-idling = { group = "androidx.test.espresso.idling", name = "idling-concurrent", version.ref = "androidx-espresso" } +androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "androidx-fragment" } +androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidx-fragment" } +compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "compose" } +compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compose" } +compose-ui-testmanifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "compose" } +compose-ui-testjunit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "compose" } +coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +dagger-runtime = { group = "com.google.dagger", name = "dagger", version.ref = "dagger" } +dagger-compiler = { group = "com.google.dagger", name = "dagger-compiler", version.ref = "dagger" } +dagger-android = { group = "com.google.dagger", name = "dagger-android", version.ref = "dagger" } +dagger-android-processor = { group = "com.google.dagger", name = "dagger-android-processor", version.ref = "dagger" } +errorprone-annotations = { group = "com.google.errorprone", name = "error_prone_annotations", version.ref = "errorprone" } +errorprone-core = { group = "com.google.errorprone", name = "error_prone_core", version.ref = "errorprone" } +kotlin-junit = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit", version.ref = "kotlin" } +kotlin-jdk7 = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk7", version.ref = "kotlin" } +kotlin-gradle = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } +kotlin-bom = { group = "org.jetbrains.kotlin", name = "kotlin-bom", version.ref = "kotlin" } +ksp-api = { group = "com.google.devtools.ksp", name = "symbol-processing-api", version.ref = "ksp" } +ksp-gradle-plugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } +mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" } +mockito-android = { group = "org.mockito", name = "mockito-android", version.ref = "mockito" } +retrofit-runtime = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +retrofit-rxjava = { group = "com.squareup.retrofit2", name = "adapter-rxjava", version.ref = "retrofit" } + +[plugins] +ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023..b1b8ef56b4 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9eae05df94..221c4f9822 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Wed Mar 17 09:33:08 PDT 2021 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-bin.zip diff --git a/gradlew b/gradlew index 4f906e0c81..249efbb032 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,81 +15,114 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f938..8508ef684d 100755 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,19 +13,22 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,15 +43,15 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -56,34 +59,24 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 -:mainEnd -if "%OS%"=="Windows_NT" endlocal +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/instrumentation/build.gradle b/instrumentation/build.gradle deleted file mode 100644 index 8d0dd4b0d4..0000000000 --- a/instrumentation/build.gradle +++ /dev/null @@ -1,44 +0,0 @@ -tasks.whenTaskAdded { task -> - if (task.name == "lint") { - task.enabled = false - } -} -apply plugin: 'com.android.application' - -dependencies { - annotationProcessor project(":annotation:compiler") - implementation project(":library") - - androidTestImplementation project(':library') - androidTestImplementation project(':mocks') - androidTestImplementation "org.mockito:mockito-android:${MOCKITO_ANDROID_VERSION}" - androidTestImplementation "androidx.test.ext:junit:${ANDROID_X_TEST_VERSION}" - androidTestImplementation "androidx.test:rules:${ANDROID_X_TEST_VERSION}" - androidTestImplementation "androidx.test:core:${ANDROID_X_TEST_CORE_VERSION}" - androidTestImplementation "com.google.truth:truth:${TRUTH_VERSION}" - androidTestImplementation "junit:junit:${JUNIT_VERSION}" - androidTestImplementation "androidx.exifinterface:exifinterface:${EXIF_INTERFACE_VERSION}" - - // Not totally clear why this is required, but it seems to be missing when tests are run on - // 4.1.2 and 4.2.0 emulators. - androidTestImplementation 'com.google.code.findbugs:jsr305:3.0.2' -} - -android { - compileSdkVersion COMPILE_SDK_VERSION as int - - defaultConfig { - applicationId 'com.bumptech.glide.instrumentation' - minSdkVersion 16 as int - targetSdkVersion TARGET_SDK_VERSION as int - versionCode 1 - versionName '1.0' - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 - } -} - diff --git a/instrumentation/build.gradle.kts b/instrumentation/build.gradle.kts new file mode 100644 index 0000000000..8a7b8003c5 --- /dev/null +++ b/instrumentation/build.gradle.kts @@ -0,0 +1,55 @@ +tasks.configureEach { + if (name == "lint") { + enabled = false + } +} + +plugins { + id("com.android.application") +} + +android { + namespace = "com.bumptech.glide.instrumentation" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + multiDexEnabled = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + buildTypes { + getByName("debug") { + isDefault = true + } + } +} + +dependencies { + annotationProcessor(project(":annotation:compiler")) + implementation(project(":library")) + implementation(libs.androidx.multidex) + implementation(libs.androidx.appcompat) + + androidTestImplementation(project(":library")) + androidTestImplementation(project(":mocks")) + androidTestImplementation(project(":testutil")) + androidTestImplementation(libs.mockito.android) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.espresso.idling) + androidTestImplementation(libs.androidx.espresso) + androidTestImplementation(libs.truth) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.exifinterface) + androidTestImplementation(libs.findbugs.jsr305) +} \ No newline at end of file diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/AsBytesTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/AsBytesTest.java index e696d99889..51ade4e7ec 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/AsBytesTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/AsBytesTest.java @@ -3,24 +3,19 @@ import static com.google.common.truth.Truth.assertThat; import android.content.Context; -import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.drawable.BitmapDrawable; import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.GlideApp; +import com.bumptech.glide.test.ModelGeneratorRule; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; -import com.google.common.io.ByteStreams; -import java.io.BufferedOutputStream; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import java.util.concurrent.TimeUnit; import org.junit.Before; import org.junit.Rule; @@ -31,6 +26,7 @@ @RunWith(AndroidJUnit4.class) public class AsBytesTest { @Rule public final TearDownGlide tearDownGlide = new TearDownGlide(); + @Rule public final ModelGeneratorRule modelGeneratorRule = new ModelGeneratorRule(); private final ConcurrencyHelper concurrency = new ConcurrencyHelper(); private Context context; @@ -149,8 +145,7 @@ public void loadVideoFilePath_asBytes_withFrameTime_providesByteOfFrame() throws @Test public void loadVideoFileUri_asBytes_providesByteOfFrame() throws IOException { byte[] data = - concurrency.get( - Glide.with(context).as(byte[].class).load(Uri.fromFile(writeVideoToFile())).submit()); + concurrency.get(Glide.with(context).as(byte[].class).load(writeVideoToFileUri()).submit()); assertThat(data).isNotNull(); assertThat(BitmapFactory.decodeByteArray(data, 0, data.length)).isNotNull(); @@ -162,7 +157,7 @@ public void loadVideoFileUri_asBytes_withFrameTime_providesByteOfFrame() throws concurrency.get( GlideApp.with(context) .as(byte[].class) - .load(Uri.fromFile(writeVideoToFile())) + .load(writeVideoToFileUri()) .frame(TimeUnit.SECONDS.toMicros(1)) .submit()); @@ -171,32 +166,10 @@ public void loadVideoFileUri_asBytes_withFrameTime_providesByteOfFrame() throws } private File writeVideoToFile() throws IOException { - byte[] videoData = loadVideoBytes(); - File parent = context.getCacheDir(); - if (!parent.mkdirs() && (!parent.exists() || !parent.isDirectory())) { - throw new IllegalStateException("Failed to mkdirs for: " + parent); - } - File toWrite = new File(parent, "temp.jpeg"); - if (toWrite.exists() && !toWrite.delete()) { - throw new IllegalStateException("Failed to delete existing temp file: " + toWrite); - } - - OutputStream os = null; - try { - os = new BufferedOutputStream(new FileOutputStream(toWrite)); - os.write(videoData); - os.close(); - } finally { - if (os != null) { - os.close(); - } - } - return toWrite; + return modelGeneratorRule.asFile(ResourceIds.raw.video); } - private byte[] loadVideoBytes() throws IOException { - Resources resources = context.getResources(); - InputStream is = resources.openRawResource(ResourceIds.raw.video); - return ByteStreams.toByteArray(is); + private Uri writeVideoToFileUri() throws IOException { + return Uri.fromFile(writeVideoToFile()); } } diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/AsFileTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/AsFileTest.java index 6b8b7202b9..b870de3741 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/AsFileTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/AsFileTest.java @@ -7,13 +7,13 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.GlideApp; -import com.bumptech.glide.test.MockModelLoader; +import com.bumptech.glide.test.ModelGeneratorRule; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.MockModelLoader; +import com.bumptech.glide.testutil.TearDownGlide; import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -25,7 +25,10 @@ @RunWith(AndroidJUnit4.class) public class AsFileTest { private static final String URL = "https://imgs.xkcd.com/comics/mc_hammer_age.png"; + + @Rule public final ModelGeneratorRule modelGeneratorRule = new ModelGeneratorRule(); @Rule public final TearDownGlide tearDownGlide = new TearDownGlide(); + private final ConcurrencyHelper concurrency = new ConcurrencyHelper(); private final Context context = ApplicationProvider.getApplicationContext(); @@ -95,27 +98,10 @@ public void asFile_withUrlAndDiskCacheStrategyAll_fails() { } private InputStream getData() { - InputStream is = null; try { - is = context.getResources().openRawResource(ResourceIds.raw.canonical); - byte[] buffer = new byte[1024 * 1024]; - ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - int read; - while ((read = is.read(buffer)) != -1) { - outputStream.write(buffer, 0, read); - } - byte[] data = outputStream.toByteArray(); - return new ByteArrayInputStream(data); + return new ByteArrayInputStream(modelGeneratorRule.asByteArray(ResourceIds.raw.canonical)); } catch (IOException e) { throw new RuntimeException(e); - } finally { - if (is != null) { - try { - is.close(); - } catch (IOException e) { - // Ignored. - } - } } } } diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/CachingTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/CachingTest.java index cf22ce3536..3eb9ffcaf9 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/CachingTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/CachingTest.java @@ -1,8 +1,6 @@ package com.bumptech.glide; -import static com.bumptech.glide.test.Matchers.anyDrawable; -import static com.bumptech.glide.test.Matchers.anyDrawableTarget; -import static com.google.common.truth.Truth.assertThat; +import static com.bumptech.glide.testutil.BitmapSubject.assertThat; import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; import static org.mockito.AdditionalMatchers.not; @@ -30,14 +28,14 @@ import com.bumptech.glide.request.FutureTarget; import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.Target; -import com.bumptech.glide.test.BitmapSubject; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.GlideApp; import com.bumptech.glide.test.ResourceIds; import com.bumptech.glide.test.ResourceIds.raw; -import com.bumptech.glide.test.TearDownGlide; -import com.bumptech.glide.test.WaitModelLoader; -import com.bumptech.glide.test.WaitModelLoader.WaitModel; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; +import com.bumptech.glide.testutil.WaitModelLoader; +import com.bumptech.glide.testutil.WaitModelLoader.WaitModel; +import com.google.common.truth.Truth; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -48,6 +46,7 @@ import org.junit.Test; import org.junit.function.ThrowingRunnable; import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -87,7 +86,11 @@ public void submit_withDisabledMemoryCache_andResourceInActiveResources_loadsFro verify(requestListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.MEMORY_CACHE), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.MEMORY_CACHE), + anyBoolean()); } @Test @@ -139,9 +142,9 @@ public void run() { verify(requestListener) .onResourceReady( - anyDrawable(), + ArgumentMatchers.any(), any(), - anyDrawableTarget(), + ArgumentMatchers.>any(), not(eq(DataSource.MEMORY_CACHE)), anyBoolean()); } @@ -178,9 +181,9 @@ public void submit_withPreviousRequestClearedFromMemory_completesFromDataDiskCac verify(requestListener) .onResourceReady( - anyDrawable(), + ArgumentMatchers.any(), any(), - anyDrawableTarget(), + ArgumentMatchers.>any(), eq(DataSource.DATA_DISK_CACHE), anyBoolean()); } @@ -209,7 +212,11 @@ public void submit_withPreviousButNoLongerReferencedIdenticalRequest_completesFr verify(requestListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.MEMORY_CACHE), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.MEMORY_CACHE), + anyBoolean()); } @Test @@ -240,7 +247,7 @@ public void submit_withPreviousButNoLongerReferencedIdenticalRequest_doesNotRecy clearMemoryCacheOnMainThread(); - BitmapSubject.assertThat(bitmap).isNotRecycled(); + assertThat(bitmap).isNotRecycled(); } @Test @@ -291,9 +298,9 @@ public void clearDiskCache_doesNotPreventFutureLoads() { // request). verify(requestListener) .onResourceReady( - anyDrawable(), + ArgumentMatchers.any(), any(), - anyDrawableTarget(), + ArgumentMatchers.>any(), eq(DataSource.DATA_DISK_CACHE), anyBoolean()); } @@ -301,7 +308,7 @@ public void clearDiskCache_doesNotPreventFutureLoads() { // Tests #2428. @Test public void onlyRetrieveFromCache_withPreviousRequestLoadingFromSource_doesNotBlock() { - final WaitModel waitModel = WaitModelLoader.Factory.waitOn(ResourceIds.raw.canonical); + final WaitModel waitModel = WaitModelLoader.waitOn(ResourceIds.raw.canonical); FutureTarget loadFromSourceFuture = GlideApp.with(context).load(waitModel).submit(); @@ -317,7 +324,7 @@ public void onlyRetrieveFromCache_withPreviousRequestLoadingFromSource_doesNotBl } waitModel.countDown(); - assertThat(concurrency.get(loadFromSourceFuture)).isNotNull(); + Truth.assertThat(concurrency.get(loadFromSourceFuture)).isNotNull(); } // Tests #2428. @@ -351,7 +358,7 @@ public void run() { blockMainThread.countDown(); // Verify that the request that didn't have retrieve from cache succeeds - assertThat(concurrency.get(expectedFuture)).isNotNull(); + Truth.assertThat(concurrency.get(expectedFuture)).isNotNull(); // The first request only from cache should fail because the item is not in cache. assertThrows( RuntimeException.class, @@ -397,7 +404,11 @@ public void run() { verify(requestListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.MEMORY_CACHE), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.MEMORY_CACHE), + anyBoolean()); } @Test @@ -436,7 +447,11 @@ public void run() { verify(requestListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.MEMORY_CACHE), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.MEMORY_CACHE), + anyBoolean()); } @Test @@ -471,9 +486,9 @@ public void loadIntoView_withSkipMemoryCache_doesNotLoadFromMemoryCacheIfPresent verify(requestListener) .onResourceReady( - anyDrawable(), + ArgumentMatchers.any(), any(), - anyDrawableTarget(), + ArgumentMatchers.>any(), not(eq(DataSource.MEMORY_CACHE)), anyBoolean()); } diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/CenterCropRegressionTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/CenterCropRegressionTest.java index d298bc0470..fb8ea5730c 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/CenterCropRegressionTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/CenterCropRegressionTest.java @@ -12,7 +12,7 @@ import com.bumptech.glide.test.RegressionTest; import com.bumptech.glide.test.SplitByCpu; import com.bumptech.glide.test.SplitBySdk; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.TearDownGlide; import java.util.concurrent.ExecutionException; import org.junit.Before; import org.junit.Rule; @@ -34,7 +34,8 @@ public class CenterCropRegressionTest { @Before public void setUp() { context = ApplicationProvider.getApplicationContext(); - bitmapRegressionTester = new BitmapRegressionTester(getClass(), testName); + bitmapRegressionTester = + BitmapRegressionTester.newInstance(getClass(), testName).assumeShouldRun(); canonical = new CanonicalBitmap(); } diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/CenterInsideRegressionTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/CenterInsideRegressionTest.java index 8749cbc89f..fd087caeb0 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/CenterInsideRegressionTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/CenterInsideRegressionTest.java @@ -12,7 +12,7 @@ import com.bumptech.glide.test.RegressionTest; import com.bumptech.glide.test.SplitByCpu; import com.bumptech.glide.test.SplitBySdk; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.TearDownGlide; import java.util.concurrent.ExecutionException; import org.junit.Before; import org.junit.Rule; @@ -34,7 +34,8 @@ public class CenterInsideRegressionTest { @Before public void setUp() { context = ApplicationProvider.getApplicationContext(); - bitmapRegressionTester = new BitmapRegressionTester(getClass(), testName); + bitmapRegressionTester = + BitmapRegressionTester.newInstance(getClass(), testName).assumeShouldRun(); canonical = new CanonicalBitmap(); } diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/CircleCropRegressionTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/CircleCropRegressionTest.java index 4858c3906f..4b1af29f6b 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/CircleCropRegressionTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/CircleCropRegressionTest.java @@ -12,7 +12,7 @@ import com.bumptech.glide.test.RegressionTest; import com.bumptech.glide.test.SplitByCpu; import com.bumptech.glide.test.SplitBySdk; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.TearDownGlide; import java.util.concurrent.ExecutionException; import org.junit.Before; import org.junit.Rule; @@ -34,7 +34,8 @@ public class CircleCropRegressionTest { @Before public void setUp() { context = ApplicationProvider.getApplicationContext(); - bitmapRegressionTester = new BitmapRegressionTester(getClass(), testName); + bitmapRegressionTester = + BitmapRegressionTester.newInstance(getClass(), testName).assumeShouldRun(); canonical = new CanonicalBitmap(); } diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/DarkModeTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/DarkModeTest.java new file mode 100644 index 0000000000..12bd0528d7 --- /dev/null +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/DarkModeTest.java @@ -0,0 +1,624 @@ +package com.bumptech.glide; + +import static androidx.test.espresso.Espresso.onIdle; +import static com.bumptech.glide.testutil.BitmapSubject.assertThat; +import static org.junit.Assume.assumeTrue; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.widget.ImageView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.test.core.app.ActivityScenario; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.bumptech.glide.instrumentation.R; +import com.bumptech.glide.load.engine.executor.IdlingGlideRule; +import com.bumptech.glide.request.target.Target; +import com.bumptech.glide.test.ForceDarkOrLightModeActivity; +import com.google.common.base.Function; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class DarkModeTest { + private final Context context = ApplicationProvider.getApplicationContext(); + + @Rule + public final IdlingGlideRule idlingGlideRule = + IdlingGlideRule.newGlideRule(glideBuilder -> glideBuilder); + + @Before + public void before() { + // Dark mode wasn't supported prior to Q. + assumeTrue(VERSION.SDK_INT >= VERSION_CODES.Q); + } + + @Test + public void load_withDarkModeActivity_vectorDrawable_usesDarkModeColor() { + runActivityDrawableTest( + darkModeActivity(), + R.drawable.vector_drawable_dark, + activity -> + Glide.with(activity).load(R.drawable.vector_drawable).override(Target.SIZE_ORIGINAL)); + } + + @Test + public void load_withLightModeActivity_vectorDrawable_usesLightModeColor() { + runActivityDrawableTest( + lightModeActivity(), + R.drawable.vector_drawable_light, + activity -> + Glide.with(activity).load(R.drawable.vector_drawable).override(Target.SIZE_ORIGINAL)); + } + + private void runActivityDrawableTest( + ActivityScenario scenario, + int expectedResource, + Function> glideBuilder) { + AtomicReference result = new AtomicReference<>(); + try (scenario) { + scenario.onActivity( + activity -> { + ViewGroup container = findContainer(activity); + ImageView imageView = newFixedSizeImageView(activity); + container.addView(imageView); + + glideBuilder.apply(activity).into(imageView); + }); + + // This two step process is because setting the Drawable on the ImageView modifies the + // drawable in a subsequent frame. If we want our Drawables to produce identical Bitmaps when + // drawn to a canvas, we need to set both on the ImageView for at least one frame. + onIdle(); + scenario.onActivity( + activity -> { + ImageView imageView = findImageView(activity); + result.set(drawableToBitmap(imageView.getDrawable())); + Drawable expectedDrawable = AppCompatResources.getDrawable(activity, expectedResource); + imageView.setImageDrawable(expectedDrawable); + }); + onIdle(); + scenario.onActivity( + activity -> { + ImageView imageView = findImageView(activity); + Bitmap expected = drawableToBitmap(imageView.getDrawable()); + assertThat(result.get()).sameAs(expected); + }); + } + } + + private static Bitmap drawableToBitmap(Drawable drawable) { + int width = drawable.getIntrinsicWidth(); + int height = drawable.getIntrinsicHeight(); + + Bitmap result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(result); + drawable.setBounds(0, 0, width, height); + drawable.draw(canvas); + canvas.setBitmap(null); + return result; + } + + @Test + public void load_withDarkModeActivity_useDarkModeDrawable() { + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + activity -> Glide.with(activity).load(R.drawable.dog).override(Target.SIZE_ORIGINAL)); + } + + @Test + public void load_withDarkModeActivity_afterLoadingWithLightModeActivity_useDarkModeDrawable() { + // Load with light mode first. + runActivityTest( + lightModeActivity(), + R.raw.dog_light, + activity -> Glide.with(activity).load(R.drawable.dog).override(Target.SIZE_ORIGINAL)); + + // Then again with dark mode to make sure that we do not use the cached resource from the + // previous load. + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + activity -> Glide.with(activity).load(R.drawable.dog).override(Target.SIZE_ORIGINAL)); + } + + @Test + public void + load_withDarkModeActivity_afterLoadingWithLightModeActivity_memoryCacheCleared_useDarkModeDrawable() { + // Load with light mode first. + runActivityTest( + lightModeActivity(), + R.raw.dog_light, + activity -> Glide.with(activity).load(R.drawable.dog).override(Target.SIZE_ORIGINAL)); + + // Then again with dark mode to make sure that we do not use the cached resource from the + // previous load. + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + activity -> { + Glide.get(context).clearMemory(); + return Glide.with(activity).load(R.drawable.dog).override(Target.SIZE_ORIGINAL); + }); + } + + @Test + public void load_withDarkModeFragment_usesDarkModeDrawable() { + runFragmentTest( + darkModeActivity(), + R.raw.dog_dark, + fragment -> Glide.with(fragment).load(R.drawable.dog).override(Target.SIZE_ORIGINAL)); + } + + @Test + public void load_withLightModeActivity_usesLightModeDrawable() { + runActivityTest( + lightModeActivity(), + R.raw.dog_light, + activity -> Glide.with(activity).load(R.drawable.dog).override(Target.SIZE_ORIGINAL)); + } + + @Test + public void load_withLightModeFragment_usesLightModeDrawable() { + runFragmentTest( + lightModeActivity(), + R.raw.dog_light, + fragment -> Glide.with(fragment).load(R.drawable.dog).override(Target.SIZE_ORIGINAL)); + } + + @Test + public void load_withDarkModeActivity_darkModeTheme_usesDarkModeDrawable() { + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + activity -> + Glide.with(activity) + .load(R.drawable.dog) + .override(Target.SIZE_ORIGINAL) + .theme(activity.getTheme())); + } + + @Test + public void loadResourceNameUri_withDarkModeActivity_darkModeTheme_usesDarkModeDrawable() { + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + activity -> + Glide.with(activity) + .load(newResourceNameUri(activity, R.drawable.dog)) + .override(Target.SIZE_ORIGINAL) + .theme(activity.getTheme())); + } + + @Test + public void loadResourceNameUri_withDarkModeActivity_usesDarkModeDrawable() { + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + activity -> + Glide.with(activity) + .load(newResourceNameUri(activity, R.drawable.dog)) + .override(Target.SIZE_ORIGINAL)); + } + + @Test + public void + loadResourceNameUri_withDarkModeActivity_afterLightModeActivity_usesDarkModeDrawable() { + runActivityTest( + lightModeActivity(), + R.raw.dog_light, + activity -> + Glide.with(activity) + .load(newResourceNameUri(activity, R.drawable.dog)) + .override(Target.SIZE_ORIGINAL)); + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + activity -> + Glide.with(activity) + .load(newResourceNameUri(activity, R.drawable.dog)) + .override(Target.SIZE_ORIGINAL)); + } + + @Test + public void loadResourceIdUri_withDarkModeActivity_darkModeTheme_usesDarkModeDrawable() { + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + activity -> + Glide.with(activity) + .load(newResourceIdUri(activity, R.drawable.dog)) + .override(Target.SIZE_ORIGINAL) + .theme(activity.getTheme())); + } + + @Test + public void loadResourceIdUri_withDarkModeActivity_usesDarkModeDrawable() { + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + activity -> + Glide.with(activity) + .load(newResourceIdUri(activity, R.drawable.dog)) + .override(Target.SIZE_ORIGINAL)); + } + + private static Uri newResourceNameUri(Context context, int resourceId) { + Resources resources = context.getResources(); + return newResourceUriBuilder(context) + .appendPath(resources.getResourceTypeName(resourceId)) + .appendPath(resources.getResourceEntryName(resourceId)) + .build(); + } + + private static Uri newResourceIdUri(Context context, int resourceId) { + return newResourceUriBuilder(context).appendPath(String.valueOf(resourceId)).build(); + } + + private static Uri.Builder newResourceUriBuilder(Context context) { + return new Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(context.getPackageName()); + } + + @Test + public void load_withDarkModeFragment_darkModeTheme_usesDarkModeDrawable() { + runFragmentTest( + darkModeActivity(), + R.raw.dog_dark, + fragment -> + Glide.with(fragment) + .load(R.drawable.dog) + .override(Target.SIZE_ORIGINAL) + .theme(fragment.requireActivity().getTheme())); + } + + @Test + public void loadResourceNameUri_withDarkModeFragment_darkModeTheme_usesDarkModeDrawable() { + runFragmentTest( + darkModeActivity(), + R.raw.dog_dark, + fragment -> + Glide.with(fragment) + .load(newResourceNameUri(fragment.requireContext(), R.drawable.dog)) + .override(Target.SIZE_ORIGINAL) + .theme(fragment.requireActivity().getTheme())); + } + + @Test + public void loadResourceIdUri_withDarkModeFragment_darkModeTheme_usesDarkModeDrawable() { + runFragmentTest( + darkModeActivity(), + R.raw.dog_dark, + fragment -> + Glide.with(fragment) + .load(newResourceIdUri(fragment.requireContext(), R.drawable.dog)) + .override(Target.SIZE_ORIGINAL) + .theme(fragment.requireActivity().getTheme())); + } + + @Test + public void load_withApplicationContext_darkTheme_usesDarkModeDrawable() { + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + input -> + Glide.with(input.getApplicationContext()) + .load(R.drawable.dog) + .override(Target.SIZE_ORIGINAL) + .theme(input.getTheme())); + } + + @Ignore("TODO(#3751): Consider how to deal with themes applied for application context loads.") + @Test + public void load_withApplicationContext_lightTheme_thenDarkTheme_usesDarkModeDrawable() { + runActivityTest( + lightModeActivity(), + R.raw.dog_light, + input -> + Glide.with(input.getApplicationContext()) + .load(R.drawable.dog) + .override(Target.SIZE_ORIGINAL) + .theme(input.getTheme())); + + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + input -> + Glide.with(input.getApplicationContext()) + .load(R.drawable.dog) + .override(Target.SIZE_ORIGINAL) + .theme(input.getTheme())); + } + + @Test + public void loadResourceNameUri_withApplicationContext_darkTheme_usesDarkModeDrawable() { + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + input -> + Glide.with(input.getApplicationContext()) + .load(newResourceNameUri(input.getApplicationContext(), R.drawable.dog)) + .override(Target.SIZE_ORIGINAL) + .theme(input.getTheme())); + } + + @Ignore("TODO(#3751): Consider how to deal with themes applied for application context loads.") + @Test + public void + loadResourceNameUri_withApplicationContext_darkTheme_afterLightTheme_usesDarkModeDrawable() { + runActivityTest( + lightModeActivity(), + R.raw.dog_light, + input -> + Glide.with(input.getApplicationContext()) + .load(newResourceNameUri(input.getApplicationContext(), R.drawable.dog)) + .override(Target.SIZE_ORIGINAL) + .theme(input.getTheme())); + + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + input -> + Glide.with(input.getApplicationContext()) + .load(newResourceNameUri(input.getApplicationContext(), R.drawable.dog)) + .override(Target.SIZE_ORIGINAL) + .theme(input.getTheme())); + } + + @Test + public void loadResourceIdUri_withApplicationContext_darkTheme_usesDarkModeDrawable() { + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + input -> + Glide.with(input.getApplicationContext()) + .load(newResourceIdUri(input.getApplicationContext(), R.drawable.dog)) + .override(Target.SIZE_ORIGINAL) + .theme(input.getTheme())); + } + + @Test + public void load_withApplicationContext_lightTheme_usesLightModeDrawable() { + runActivityTest( + lightModeActivity(), + R.raw.dog_light, + input -> + Glide.with(input.getApplicationContext()) + .load(R.drawable.dog) + .override(Target.SIZE_ORIGINAL) + .theme(input.getTheme())); + } + + @Test + public void load_withLightModeActivity_lightModeTheme_usesLightModeDrawable() { + runActivityTest( + lightModeActivity(), + R.raw.dog_light, + activity -> + Glide.with(activity) + .load(R.drawable.dog) + .override(Target.SIZE_ORIGINAL) + .theme(activity.getTheme())); + } + + @Test + public void placeholder_withDarkModeActivity_usesDarkModeDrawable() { + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + input -> Glide.with(input).load((Object) null).placeholder(R.drawable.dog)); + } + + @Test + public void placeholder_withDarkModeFragment_usesDarkModeDrawable() { + runFragmentTest( + darkModeActivity(), + R.raw.dog_dark, + input -> Glide.with(input).load((Object) null).placeholder(R.drawable.dog)); + } + + @Test + public void error_withDarkModeActivity_usesDarkModeDrawable() { + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + input -> Glide.with(input).load((Object) null).error(R.drawable.dog)); + } + + @Test + public void error_withDarkModeFragment_usesDarkModeDrawable() { + runFragmentTest( + darkModeActivity(), + R.raw.dog_dark, + input -> Glide.with(input).load((Object) null).error(R.drawable.dog)); + } + + @Test + public void fallback_withDarkModeActivity_usesDarkModeDrawable() { + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + input -> Glide.with(input).load((Object) null).fallback(R.drawable.dog)); + } + + @Test + public void fallback_withDarkModeFragment_usesDarkModeDrawable() { + runFragmentTest( + darkModeActivity(), + R.raw.dog_dark, + input -> Glide.with(input).load((Object) null).fallback(R.drawable.dog)); + } + + @Test + public void placeholder_withLightModeActivity_usesLightModeDrawable() { + runActivityTest( + lightModeActivity(), + R.raw.dog_light, + input -> Glide.with(input).load((Object) null).placeholder(R.drawable.dog)); + } + + @Test + public void placeholder_withLightModeFragment_usesLightModeDrawable() { + runFragmentTest( + lightModeActivity(), + R.raw.dog_light, + input -> Glide.with(input).load((Object) null).placeholder(R.drawable.dog)); + } + + @Test + public void placeholder_withDarkModeActivityAndTheme_usesDarkModeDrawable() { + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + input -> + Glide.with(input) + .load((Object) null) + .theme(input.getTheme()) + .placeholder(R.drawable.dog)); + } + + @Test + public void placeholder_withLightModeActivityAndTheme_usesLightModeDrawable() { + runActivityTest( + lightModeActivity(), + R.raw.dog_light, + input -> + Glide.with(input) + .load((Object) null) + .theme(input.getTheme()) + .placeholder(R.drawable.dog)); + } + + @Test + public void placeholder_withApplicationContext_darkTheme_usesDarkModeDrawable() { + runActivityTest( + darkModeActivity(), + R.raw.dog_dark, + input -> + Glide.with(input.getApplicationContext()) + .load((Object) null) + .theme(input.getTheme()) + .placeholder(R.drawable.dog)); + } + + @Test + public void placeholder_withApplicationContext_lightTheme_usesLightModeDrawable() { + runActivityTest( + lightModeActivity(), + R.raw.dog_light, + input -> + Glide.with(input.getApplicationContext()) + .load((Object) null) + .theme(input.getTheme()) + .placeholder(R.drawable.dog)); + } + + private ActivityScenario darkModeActivity() { + return ActivityScenario.launch(ForceDarkOrLightModeActivity.forceDarkMode(context)); + } + + private ActivityScenario lightModeActivity() { + return ActivityScenario.launch(ForceDarkOrLightModeActivity.forceLightMode(context)); + } + + private static void runFragmentTest( + ActivityScenario scenario, + int expectedResource, + Function> requestBuilder) { + try (scenario) { + scenario.onActivity( + activity -> { + ImageViewFragment fragment = new ImageViewFragment(); + activity + .getSupportFragmentManager() + .beginTransaction() + .add(R.id.container, fragment) + .commitNowAllowingStateLoss(); + ViewGroup container = findContainer(activity); + ImageView imageView = (ImageView) container.getChildAt(0); + + requestBuilder.apply(fragment).into(imageView); + }); + + assertImageViewContainerChildHasContent(scenario, expectedResource); + } + } + + /** Fragment that displays a single fixed size ImageView. */ + public static final class ImageViewFragment extends Fragment { + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return newFixedSizeImageView(getContext()); + } + } + + private static ImageView newFixedSizeImageView(Context context) { + ImageView imageView = new ImageView(context); + imageView.setLayoutParams(new LayoutParams(200, 200)); + return imageView; + } + + private static void runActivityTest( + ActivityScenario scenario, + int expectedResource, + Function> glideBuilder) { + try (scenario) { + scenario.onActivity( + activity -> { + ViewGroup container = findContainer(activity); + ImageView imageView = newFixedSizeImageView(activity); + container.addView(imageView); + + glideBuilder.apply(activity).into(imageView); + }); + + assertImageViewContainerChildHasContent(scenario, expectedResource); + } + } + + private static void assertImageViewContainerChildHasContent( + ActivityScenario scenario, int expectedResource) { + onIdle(); + scenario.onActivity( + activity -> { + ImageView imageView = findImageView(activity); + Bitmap bitmap = ((BitmapDrawable) imageView.getDrawable()).getBitmap(); + assertThat(bitmap).sameAs(expectedResource); + }); + } + + private static ImageView findImageView(FragmentActivity activity) { + ViewGroup container = findContainer(activity); + return (ImageView) container.getChildAt(0); + } + + private static ViewGroup findContainer(FragmentActivity activity) { + return activity.findViewById(R.id.container); + } +} diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/DataUriTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/DataUriTest.java index df17316805..b0cf023e96 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/DataUriTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/DataUriTest.java @@ -12,9 +12,9 @@ import androidx.core.content.ContextCompat; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; import com.bumptech.glide.util.Preconditions; import java.io.ByteArrayOutputStream; import org.junit.Rule; @@ -92,6 +92,6 @@ private String getBase64BitmapBytes(CompressFormat format) { Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap(); bitmap.compress(format, 100, bos); byte[] data = bos.toByteArray(); - return Base64.encodeToString(data, /*flags=*/ 0); + return Base64.encodeToString(data, /* flags= */ 0); } } diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/DownsampleVideoTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/DownsampleVideoTest.java index 5873ce58ba..bdffad6af3 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/DownsampleVideoTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/DownsampleVideoTest.java @@ -1,6 +1,6 @@ package com.bumptech.glide; -import static com.bumptech.glide.test.BitmapSubject.assertThat; +import static com.bumptech.glide.testutil.BitmapSubject.assertThat; import static org.junit.Assume.assumeTrue; import android.content.Context; @@ -10,10 +10,10 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy; import com.bumptech.glide.request.target.Target; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.GlideApp; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; import org.junit.Before; import org.junit.Rule; import org.junit.Test; diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/DrawableTransformationTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/DrawableTransformationTest.java index 7bc31c24b2..1d7998da16 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/DrawableTransformationTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/DrawableTransformationTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide; +import static com.bumptech.glide.testutil.BitmapSubject.assertThat; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.mock; @@ -20,8 +21,8 @@ import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; import com.bumptech.glide.load.resource.bitmap.TransformationUtils; import com.bumptech.glide.request.RequestOptions; -import com.bumptech.glide.test.BitmapSubject; import com.bumptech.glide.test.GlideApp; +import com.google.common.truth.Truth; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -57,7 +58,7 @@ public void load_withColorDrawable_sizeOriginal_optionalTransform_returnsColorDr .submit() .get(); - assertThat(result).isInstanceOf(ColorDrawable.class); + Truth.assertThat(result).isInstanceOf(ColorDrawable.class); assertThat(((ColorDrawable) result).getColor()).isEqualTo(Color.RED); } @@ -74,7 +75,7 @@ public void load_withColorDrawable_fixedSize_requiredUnitTransform_returnsOrigin .submit(100, 100) .get(); - assertThat(result).isInstanceOf(ColorDrawable.class); + Truth.assertThat(result).isInstanceOf(ColorDrawable.class); assertThat(((ColorDrawable) result).getColor()).isEqualTo(Color.RED); } @@ -103,7 +104,7 @@ public void load_withColorDrawable_fixedSize_nonUnitRequiredTransform_returnsBit .thenReturn(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)); Bitmap expected = TransformationUtils.circleCrop(bitmapPool, redSquare, 100, 100); - assertThat(result).isInstanceOf(BitmapDrawable.class); + Truth.assertThat(result).isInstanceOf(BitmapDrawable.class); Bitmap bitmap = ((BitmapDrawable) result).getBitmap(); assertThat(bitmap.getWidth()).isEqualTo(100); assertThat(bitmap.getHeight()).isEqualTo(100); @@ -169,7 +170,7 @@ public void load_withBitmapDrawable_andDoNothingTransformation_doesNotRecycleBit .submit() .get(); - BitmapSubject.assertThat(result).isNotRecycled(); + assertThat(result).isNotRecycled(); } @Test @@ -186,7 +187,7 @@ public void load_withBitmapDrawable_andFunctionalTransformation_doesNotRecycleBi .submit() .get(); - BitmapSubject.assertThat(result).isNotRecycled(); + assertThat(result).isNotRecycled(); } @Test @@ -223,7 +224,7 @@ public void load_withColorDrawable_fixedSize_functionalBitmapTransform_doesNotRe .submit() .get(); - BitmapSubject.assertThat(result).isNotRecycled(); + assertThat(result).isNotRecycled(); BitmapPool bitmapPool = Glide.get(context).getBitmapPool(); // Make sure we didn't put the same Bitmap twice. diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/ErrorHandlingTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/ErrorHandlingTest.java index 943e76c58a..41bd648da7 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/ErrorHandlingTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/ErrorHandlingTest.java @@ -1,7 +1,5 @@ package com.bumptech.glide; -import static com.bumptech.glide.test.Matchers.anyDrawable; -import static com.bumptech.glide.test.Matchers.anyDrawableTarget; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -10,6 +8,7 @@ import android.content.Context; import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -23,19 +22,22 @@ import com.bumptech.glide.load.engine.Resource; import com.bumptech.glide.load.engine.executor.GlideExecutor; import com.bumptech.glide.load.engine.executor.GlideExecutor.UncaughtThrowableStrategy; +import com.bumptech.glide.load.resource.bitmap.BitmapDrawableEncoder; import com.bumptech.glide.request.FutureTarget; import com.bumptech.glide.request.RequestListener; -import com.bumptech.glide.test.ConcurrencyHelper; +import com.bumptech.glide.request.target.Target; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; -import com.bumptech.glide.test.WaitModelLoader; -import com.bumptech.glide.test.WaitModelLoader.WaitModel; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; +import com.bumptech.glide.testutil.WaitModelLoader; +import com.bumptech.glide.testutil.WaitModelLoader.WaitModel; import java.io.File; import java.util.concurrent.CountDownLatch; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -54,18 +56,30 @@ public void setUp() { context = ApplicationProvider.getApplicationContext(); } - // ResourceEncoders are expected not to throw and to return true or false. If they do throw, it's - // a developer error, so we expect UncaughtThrowableStrategy to be called. - @Test - public void load_whenEncoderFails_callsUncaughtThrowableStrategy() { + private WaitForErrorStrategy initializeGlideWithWaitForErrorStrategy() { WaitForErrorStrategy strategy = new WaitForErrorStrategy(); Glide.init( context, new GlideBuilder() - .setAnimationExecutor(GlideExecutor.newAnimationExecutor(/*threadCount=*/ 1, strategy)) + .setAnimationExecutor( + GlideExecutor.newAnimationExecutor(/* threadCount= */ 1, strategy)) .setSourceExecutor(GlideExecutor.newSourceExecutor(strategy)) .setDiskCacheExecutor(GlideExecutor.newDiskCacheExecutor(strategy))); - Glide.get(context).getRegistry().prepend(Bitmap.class, new FailEncoder()); + Glide.get(context) + .getRegistry() + .prepend(Bitmap.class, new FailEncoder()) + .prepend( + BitmapDrawable.class, + new BitmapDrawableEncoder(Glide.get(context).getBitmapPool(), new FailEncoder())); + + return strategy; + } + + // ResourceEncoders are expected not to throw and to return true or false. If they do throw, it's + // a developer error, so we expect UncaughtThrowableStrategy to be called. + @Test + public void load_whenEncoderFails_callsUncaughtThrowableStrategy() { + WaitForErrorStrategy strategy = initializeGlideWithWaitForErrorStrategy(); concurrency.get( Glide.with(context).load(ResourceIds.raw.canonical).listener(requestListener).submit()); @@ -76,33 +90,38 @@ public void load_whenEncoderFails_callsUncaughtThrowableStrategy() { assertThat(strategy.error).isEqualTo(FailEncoder.TO_THROW); verify(requestListener, never()) - .onLoadFailed(any(GlideException.class), any(), anyDrawableTarget(), anyBoolean()); + .onLoadFailed( + any(GlideException.class), + any(), + ArgumentMatchers.>any(), + anyBoolean()); } @Test public void load_whenLoadSucceeds_butEncoderFails_doesNotCallOnLoadFailed() { - WaitForErrorStrategy strategy = new WaitForErrorStrategy(); - Glide.init( - context, - new GlideBuilder() - .setAnimationExecutor(GlideExecutor.newAnimationExecutor(/*threadCount=*/ 1, strategy)) - .setSourceExecutor(GlideExecutor.newSourceExecutor(strategy)) - .setDiskCacheExecutor(GlideExecutor.newDiskCacheExecutor(strategy))); - Glide.get(context).getRegistry().prepend(Bitmap.class, new FailEncoder()); + WaitForErrorStrategy strategy = initializeGlideWithWaitForErrorStrategy(); concurrency.get( Glide.with(context).load(ResourceIds.raw.canonical).listener(requestListener).submit()); verify(requestListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), any(DataSource.class), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + any(DataSource.class), + anyBoolean()); verify(requestListener, never()) - .onLoadFailed(any(GlideException.class), any(), anyDrawableTarget(), anyBoolean()); + .onLoadFailed( + any(GlideException.class), + any(), + ArgumentMatchers.>any(), + anyBoolean()); } @Test public void clearRequest_withError_afterPrimaryFails_clearsErrorRequest() { - WaitModel errorModel = WaitModelLoader.Factory.waitOn(ResourceIds.raw.canonical); + WaitModel errorModel = WaitModelLoader.waitOn(ResourceIds.raw.canonical); FutureTarget target = Glide.with(context) diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/ExternallyClearedDiskCacheTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/ExternallyClearedDiskCacheTest.java index 1bf471a365..3d3c53ea60 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/ExternallyClearedDiskCacheTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/ExternallyClearedDiskCacheTest.java @@ -11,10 +11,10 @@ import com.bumptech.glide.load.engine.cache.DiskCache; import com.bumptech.glide.load.engine.cache.DiskCache.Factory; import com.bumptech.glide.load.engine.cache.DiskLruCacheWrapper; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.ResourceIds; import com.bumptech.glide.test.ResourceIds.raw; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; import java.io.File; import org.junit.After; import org.junit.Before; diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/FitCenterRegressionTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/FitCenterRegressionTest.java index dbb5342a31..7159836825 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/FitCenterRegressionTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/FitCenterRegressionTest.java @@ -13,7 +13,7 @@ import com.bumptech.glide.test.RegressionTest; import com.bumptech.glide.test.SplitByCpu; import com.bumptech.glide.test.SplitBySdk; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.TearDownGlide; import java.util.concurrent.ExecutionException; import org.junit.Before; import org.junit.Rule; @@ -36,7 +36,8 @@ public class FitCenterRegressionTest { @Before public void setUp() { context = ApplicationProvider.getApplicationContext(); - bitmapRegressionTester = new BitmapRegressionTester(getClass(), testName); + bitmapRegressionTester = + BitmapRegressionTester.newInstance(getClass(), testName).assumeShouldRun(); canonical = new CanonicalBitmap(); } diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/LargeImageTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/LargeImageTest.java index 17c547f2e5..7e1161a962 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/LargeImageTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/LargeImageTest.java @@ -10,9 +10,9 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.bumptech.glide.load.model.UnitModelLoader; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/LoadAnimatedImageResourceTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/LoadAnimatedImageResourceTest.java new file mode 100644 index 0000000000..691b0b9777 --- /dev/null +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/LoadAnimatedImageResourceTest.java @@ -0,0 +1,99 @@ +package com.bumptech.glide; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assume.assumeTrue; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.drawable.AnimatedImageDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.bumptech.glide.test.GlideApp; +import com.bumptech.glide.test.ResourceIds; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; +import java.io.IOException; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockitoAnnotations; + +/** + * Tests that Glide is able to load animated images (WebP and AVIF) stored in resources and loaded + * as {@link android.graphics.drawable.AnimatedImageDrawable}s when the underlying Android platform + * supports it. + */ +@RunWith(AndroidJUnit4.class) +public class LoadAnimatedImageResourceTest { + @Rule public final TearDownGlide tearDownGlide = new TearDownGlide(); + private final ConcurrencyHelper concurrency = new ConcurrencyHelper(); + + private Context context; + + private static final boolean IS_ANIMATED_WEBP_SUPPORTED = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P; + private static final boolean IS_ANIMATED_AVIF_SUPPORTED = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S; + + @Before + public void setUp() throws IOException { + MockitoAnnotations.initMocks(this); + context = ApplicationProvider.getApplicationContext(); + } + + @Test + public void loadAnimatedImageResourceId_fromInt_decodesAnimatedImageDrawable_Webp() { + assumeTrue(IS_ANIMATED_WEBP_SUPPORTED); + Drawable frame = + concurrency.get(Glide.with(context).load(ResourceIds.raw.animated_webp).submit()); + + assertThat(frame).isNotNull(); + assertThat(frame).isInstanceOf(AnimatedImageDrawable.class); + } + + @Test + public void loadAnimatedImageResourceId_fromInt_decodesAnimatedImageDrawable_Avif() { + assumeTrue(IS_ANIMATED_AVIF_SUPPORTED); + Drawable frame = + concurrency.get(Glide.with(context).load(ResourceIds.raw.animated_avif).submit()); + + assertThat(frame).isNotNull(); + assertThat(frame).isInstanceOf(AnimatedImageDrawable.class); + } + + @Test + public void loadAnimatedImageUri_fromId_decodesAnimatedImageDrawable_Webp() { + assumeTrue(IS_ANIMATED_WEBP_SUPPORTED); + Uri uri = + new Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(context.getPackageName()) + .path(String.valueOf(ResourceIds.raw.animated_webp)) + .build(); + + Drawable frame = concurrency.get(GlideApp.with(context).load(uri).submit()); + + assertThat(frame).isNotNull(); + assertThat(frame).isInstanceOf(AnimatedImageDrawable.class); + } + + @Test + public void loadAnimatedImageUri_fromId_decodesAnimatedImageDrawable_Avif() { + assumeTrue(IS_ANIMATED_AVIF_SUPPORTED); + Uri uri = + new Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(context.getPackageName()) + .path(String.valueOf(ResourceIds.raw.animated_avif)) + .build(); + + Drawable frame = concurrency.get(GlideApp.with(context).load(uri).submit()); + + assertThat(frame).isNotNull(); + assertThat(frame).isInstanceOf(AnimatedImageDrawable.class); + } +} diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/LoadAssetUriTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/LoadAssetUriTest.java new file mode 100644 index 0000000000..d7b5573e0a --- /dev/null +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/LoadAssetUriTest.java @@ -0,0 +1,128 @@ +package com.bumptech.glide; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.bumptech.glide.test.GlideApp; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockitoAnnotations; + +/** + * Tests that Glide is able to load images and videos stored in assets and loaded as {@link + * android.content.res.AssetFileDescriptor}s. + */ +@RunWith(AndroidJUnit4.class) +public class LoadAssetUriTest { + @Rule public final TearDownGlide tearDownGlide = new TearDownGlide(); + private final ConcurrencyHelper concurrency = new ConcurrencyHelper(); + private static final String VIDEO_ASSET_NAME = "video.mp4"; + private static final String IMAGE_ASSET_NAME = "canonical.jpg"; + + private Context context; + + @Before + public void setUp() throws IOException { + MockitoAnnotations.initMocks(this); + context = ApplicationProvider.getApplicationContext(); + } + + @Test + public void loadVideoAssetUri_decodesFrame() { + Uri uri = Uri.parse(assetNameToUri(VIDEO_ASSET_NAME)); + + Drawable frame = concurrency.get(GlideApp.with(context).load(uri).submit()); + + assertThat(frame).isNotNull(); + } + + @Test + public void loadVideoAssetUri_asBitmap_decodesFrame() { + Uri uri = Uri.parse(assetNameToUri(VIDEO_ASSET_NAME)); + + Bitmap frame = concurrency.get(GlideApp.with(context).asBitmap().load(uri).submit()); + + assertThat(frame).isNotNull(); + } + + @Test + public void loadVideoAssetUri_withFrame_decodesFrame() { + Uri uri = Uri.parse(assetNameToUri(VIDEO_ASSET_NAME)); + + Bitmap frame = + concurrency.get( + GlideApp.with(context) + .asBitmap() + .load(uri) + .frame(TimeUnit.SECONDS.toMicros(1)) + .submit()); + + assertThat(frame).isNotNull(); + } + + @Test + public void loadVideoAssetUriString_decodesFrame() { + Uri uri = Uri.parse(assetNameToUri(VIDEO_ASSET_NAME)); + + Bitmap frame = concurrency.get(GlideApp.with(context).asBitmap().load(uri.toString()).submit()); + + assertThat(frame).isNotNull(); + } + + @Test + public void loadVideoAssetUriString_withFrame_decodesFrame() { + Uri uri = Uri.parse(assetNameToUri(VIDEO_ASSET_NAME)); + + Bitmap frame = + concurrency.get( + GlideApp.with(context) + .asBitmap() + .load(uri.toString()) + .frame(TimeUnit.SECONDS.toMicros(1)) + .submit()); + + assertThat(frame).isNotNull(); + } + + @Test + public void loadImageAssetUri_decodesImage() { + Uri uri = Uri.parse(assetNameToUri(IMAGE_ASSET_NAME)); + + Drawable image = concurrency.get(GlideApp.with(context).load(uri).submit()); + + assertThat(image).isNotNull(); + } + + @Test + public void loadImageAssetUri_asBitmap_decodesImage() { + Uri uri = Uri.parse(assetNameToUri(IMAGE_ASSET_NAME)); + + Bitmap image = concurrency.get(GlideApp.with(context).asBitmap().load(uri).submit()); + + assertThat(image).isNotNull(); + } + + @Test + public void loadImageAssetUriString_decodesImage() { + Uri uri = Uri.parse(assetNameToUri(IMAGE_ASSET_NAME)); + + Bitmap image = concurrency.get(GlideApp.with(context).asBitmap().load(uri.toString()).submit()); + + assertThat(image).isNotNull(); + } + + private static String assetNameToUri(String assetName) { + return "file:///android_asset/" + assetName; + } +} diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/LoadBitmapTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/LoadBitmapTest.java index fc6d8ffbac..c87973d8ef 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/LoadBitmapTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/LoadBitmapTest.java @@ -1,9 +1,5 @@ package com.bumptech.glide; -import static com.bumptech.glide.test.Matchers.anyBitmap; -import static com.bumptech.glide.test.Matchers.anyBitmapTarget; -import static com.bumptech.glide.test.Matchers.anyDrawable; -import static com.bumptech.glide.test.Matchers.anyDrawableTarget; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -26,15 +22,16 @@ import com.bumptech.glide.load.engine.executor.MockGlideExecutor; import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.Target; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.GlideApp; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; import com.bumptech.glide.util.Util; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -189,7 +186,11 @@ public void run() { verify(drawableListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.LOCAL), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.LOCAL), + anyBoolean()); } @Test @@ -224,7 +225,11 @@ public void run() { verify(drawableListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.LOCAL), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.LOCAL), + anyBoolean()); } @Test @@ -264,7 +269,11 @@ public void run() { verify(drawableListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.LOCAL), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.LOCAL), + anyBoolean()); } @Test @@ -299,7 +308,12 @@ public void run() { .submit(100, 100)); verify(bitmapListener) - .onResourceReady(anyBitmap(), any(), anyBitmapTarget(), eq(DataSource.LOCAL), anyBoolean()); + .onResourceReady( + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.LOCAL), + anyBoolean()); } @Test @@ -340,6 +354,11 @@ public void run() { .submit(100, 100)); verify(bitmapListener) - .onResourceReady(anyBitmap(), any(), anyBitmapTarget(), eq(DataSource.LOCAL), anyBoolean()); + .onResourceReady( + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.LOCAL), + anyBoolean()); } } diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/LoadBytesTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/LoadBytesTest.java index 680029e2cb..caecbe7180 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/LoadBytesTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/LoadBytesTest.java @@ -1,8 +1,7 @@ package com.bumptech.glide; import static com.bumptech.glide.test.GlideOptions.skipMemoryCacheOf; -import static com.bumptech.glide.test.Matchers.anyDrawable; -import static com.bumptech.glide.test.Matchers.anyDrawableTarget; +import static com.bumptech.glide.testutil.BitmapSubject.assertThat; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -27,22 +26,21 @@ import com.bumptech.glide.load.engine.executor.MockGlideExecutor; import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.Target; -import com.bumptech.glide.test.BitmapSubject; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.GlideApp; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; import com.google.common.io.ByteStreams; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; -import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -63,7 +61,7 @@ public void setUp() throws IOException { imageView = new ImageView(context); int[] dimensions = getCanonicalDimensions(); - imageView.setLayoutParams(new LayoutParams(/*w=*/ dimensions[0], /*h=*/ dimensions[1])); + imageView.setLayoutParams(new LayoutParams(/* w= */ dimensions[0], /* h= */ dimensions[1])); // Writes to the resource disk cache run in a non-blocking manner after the Target is notified. // Unless we enforce a single threaded executor, the encode task races with our second decode @@ -82,7 +80,7 @@ public void setUp() throws IOException { @Test public void loadFromRequestManager_intoImageView_withDifferentByteArrays_loadsDifferentImages() - throws IOException, ExecutionException, InterruptedException { + throws IOException { final byte[] canonicalBytes = getCanonicalBytes(); final byte[] modifiedBytes = getModifiedBytes(); @@ -94,20 +92,20 @@ public void loadFromRequestManager_intoImageView_withDifferentByteArrays_loadsDi // This assertion alone doesn't catch the case where the second Bitmap is loaded from the result // cache of the data from the first Bitmap. - BitmapSubject.assertThat(firstBitmap).isNotSameInstanceAs(secondBitmap); + assertThat(firstBitmap).isNotSameInstanceAs(secondBitmap); Bitmap expectedCanonicalBitmap = - BitmapFactory.decodeByteArray(canonicalBytes, /*offset=*/ 0, canonicalBytes.length); - BitmapSubject.assertThat(firstBitmap).sameAs(expectedCanonicalBitmap); + BitmapFactory.decodeByteArray(canonicalBytes, /* offset= */ 0, canonicalBytes.length); + assertThat(firstBitmap).sameAs(expectedCanonicalBitmap); Bitmap expectedModifiedBitmap = - BitmapFactory.decodeByteArray(modifiedBytes, /*offset=*/ 0, modifiedBytes.length); - BitmapSubject.assertThat(secondBitmap).sameAs(expectedModifiedBitmap); + BitmapFactory.decodeByteArray(modifiedBytes, /* offset= */ 0, modifiedBytes.length); + assertThat(secondBitmap).sameAs(expectedModifiedBitmap); } @Test public void loadFromRequestBuilder_intoImageView_withDifferentByteArrays_loadsDifferentImages() - throws IOException, ExecutionException, InterruptedException { + throws IOException { final byte[] canonicalBytes = getCanonicalBytes(); final byte[] modifiedBytes = getModifiedBytes(); @@ -121,15 +119,15 @@ public void loadFromRequestBuilder_intoImageView_withDifferentByteArrays_loadsDi // This assertion alone doesn't catch the case where the second Bitmap is loaded from the result // cache of the data from the first Bitmap. - BitmapSubject.assertThat(firstBitmap).isNotSameInstanceAs(secondBitmap); + assertThat(firstBitmap).isNotSameInstanceAs(secondBitmap); Bitmap expectedCanonicalBitmap = - BitmapFactory.decodeByteArray(canonicalBytes, /*offset=*/ 0, canonicalBytes.length); - BitmapSubject.assertThat(firstBitmap).sameAs(expectedCanonicalBitmap); + BitmapFactory.decodeByteArray(canonicalBytes, /* offset= */ 0, canonicalBytes.length); + assertThat(firstBitmap).sameAs(expectedCanonicalBitmap); Bitmap expectedModifiedBitmap = - BitmapFactory.decodeByteArray(modifiedBytes, /*offset=*/ 0, modifiedBytes.length); - BitmapSubject.assertThat(secondBitmap).sameAs(expectedModifiedBitmap); + BitmapFactory.decodeByteArray(modifiedBytes, /* offset= */ 0, modifiedBytes.length); + assertThat(secondBitmap).sameAs(expectedModifiedBitmap); } @Test @@ -150,7 +148,11 @@ public void requestManager_intoImageView_withSameByteArrayAndMemoryCacheEnabled_ verify(requestListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.MEMORY_CACHE), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.MEMORY_CACHE), + anyBoolean()); } @Test @@ -173,7 +175,11 @@ public void requestBuilder_intoImageView_withSameByteArrayAndMemoryCacheEnabled_ verify(requestListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.MEMORY_CACHE), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.MEMORY_CACHE), + anyBoolean()); } @Test @@ -205,9 +211,9 @@ public void run() { verify(requestListener) .onResourceReady( - anyDrawable(), + ArgumentMatchers.any(), any(), - anyDrawableTarget(), + ArgumentMatchers.>any(), eq(DataSource.RESOURCE_DISK_CACHE), anyBoolean()); } @@ -243,9 +249,9 @@ public void run() { verify(requestListener) .onResourceReady( - anyDrawable(), + ArgumentMatchers.any(), any(), - anyDrawableTarget(), + ArgumentMatchers.>any(), eq(DataSource.RESOURCE_DISK_CACHE), anyBoolean()); } @@ -267,7 +273,11 @@ public void loadFromRequestManager_withSameByteArray_memoryCacheEnabled_returnsF verify(requestListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.MEMORY_CACHE), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.MEMORY_CACHE), + anyBoolean()); } @Test @@ -289,7 +299,11 @@ public void loadFromRequestBuilder_withSameByteArray_memoryCacheEnabled_returnsF verify(requestListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.MEMORY_CACHE), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.MEMORY_CACHE), + anyBoolean()); } @Test @@ -302,7 +316,11 @@ public void loadFromRequestManager_withSameByteArray_returnsFromLocal() throws I verify(requestListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.LOCAL), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.LOCAL), + anyBoolean()); } @Test @@ -317,7 +335,11 @@ public void loadFromRequestBuilder_withSameByteArray_returnsFromLocal() throws I verify(requestListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.LOCAL), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.LOCAL), + anyBoolean()); } @Test @@ -339,7 +361,11 @@ public void run() { verify(requestListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.LOCAL), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.LOCAL), + anyBoolean()); } @Test @@ -363,7 +389,11 @@ public void run() { verify(requestListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.LOCAL), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.LOCAL), + anyBoolean()); } @Test @@ -395,9 +425,9 @@ public void run() { verify(requestListener) .onResourceReady( - anyDrawable(), + ArgumentMatchers.any(), any(), - anyDrawableTarget(), + ArgumentMatchers.>any(), eq(DataSource.RESOURCE_DISK_CACHE), anyBoolean()); } @@ -427,7 +457,11 @@ public void run() { verify(requestListener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.MEMORY_CACHE), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.MEMORY_CACHE), + anyBoolean()); } @Test @@ -452,9 +486,9 @@ public void loadFromBuilder_withDataDiskCacheStrategy_returnsFromSource() throws verify(requestListener) .onResourceReady( - anyDrawable(), + ArgumentMatchers.any(), any(), - anyDrawableTarget(), + ArgumentMatchers.>any(), eq(DataSource.DATA_DISK_CACHE), anyBoolean()); } @@ -472,7 +506,7 @@ private Bitmap copyFromImageViewDrawable(ImageView imageView) { private int[] getCanonicalDimensions() throws IOException { byte[] canonicalBytes = getCanonicalBytes(); Bitmap bitmap = - BitmapFactory.decodeByteArray(canonicalBytes, /*offset=*/ 0, canonicalBytes.length); + BitmapFactory.decodeByteArray(canonicalBytes, /* offset= */ 0, canonicalBytes.length); return new int[] {bitmap.getWidth(), bitmap.getHeight()}; } @@ -480,7 +514,7 @@ private byte[] getModifiedBytes() throws IOException { int[] dimensions = getCanonicalDimensions(); Bitmap bitmap = Bitmap.createBitmap(dimensions[0], dimensions[1], Config.ARGB_8888); ByteArrayOutputStream os = new ByteArrayOutputStream(); - bitmap.compress(CompressFormat.PNG, /*quality=*/ 100, os); + bitmap.compress(CompressFormat.PNG, /* quality= */ 100, os); return os.toByteArray(); } diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/LoadDrawableTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/LoadDrawableTest.java index db9e9a8f2a..aac451711e 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/LoadDrawableTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/LoadDrawableTest.java @@ -1,7 +1,5 @@ package com.bumptech.glide; -import static com.bumptech.glide.test.Matchers.anyDrawable; -import static com.bumptech.glide.test.Matchers.anyDrawableTarget; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -25,15 +23,16 @@ import com.bumptech.glide.load.engine.executor.MockGlideExecutor; import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.target.Target; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.GlideApp; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; import com.bumptech.glide.util.Util; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -123,7 +122,11 @@ public void run() { verify(listener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.LOCAL), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.LOCAL), + anyBoolean()); } @Test @@ -155,7 +158,11 @@ public void run() { verify(listener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.LOCAL), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.LOCAL), + anyBoolean()); } @Test @@ -192,6 +199,10 @@ public void run() { verify(listener) .onResourceReady( - anyDrawable(), any(), anyDrawableTarget(), eq(DataSource.LOCAL), anyBoolean()); + ArgumentMatchers.any(), + any(), + ArgumentMatchers.>any(), + eq(DataSource.LOCAL), + anyBoolean()); } } diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/LoadResourcesWithDownsamplerTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/LoadResourcesWithDownsamplerTest.java index 8c68b91a63..8517e311ff 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/LoadResourcesWithDownsamplerTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/LoadResourcesWithDownsamplerTest.java @@ -24,10 +24,10 @@ import com.bumptech.glide.load.model.MultiModelLoaderFactory; import com.bumptech.glide.load.resource.bitmap.Downsampler; import com.bumptech.glide.signature.ObjectKey; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.GlideApp; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; import com.bumptech.glide.util.Util; import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; @@ -78,8 +78,15 @@ public void loadWideGamutJpegResource_withNoOtherLoaders_decodesWideGamutBitmap( Bitmap bitmap = concurrency.get(Glide.with(context).asBitmap().load(new Object()).submit()); assertThat(bitmap).isNotNull(); assertThat(bitmap.getConfig()).isEqualTo(Bitmap.Config.RGBA_F16); + + // The exact value here depends on the emulator / device we're running on. On Pixel devices and + // emulators it'll return DISPLAY_P3. On 'generic' emulators and some other devices, it'll + // return LINEAR_EXTENDED_SRGB. It's unclear how else we can assert correctly based on the + // device type, so I've just left this is isAnyOf for now. assertThat(bitmap.getColorSpace()) - .isEqualTo(ColorSpace.get(ColorSpace.Named.LINEAR_EXTENDED_SRGB)); + .isAnyOf( + ColorSpace.get(ColorSpace.Named.DISPLAY_P3), + ColorSpace.get(ColorSpace.Named.LINEAR_EXTENDED_SRGB)); } @Test @@ -123,8 +130,8 @@ public void loadTransparentGifResource_withNoOtherLoaders_decodesResource() { public void loadTransparentGifResource_asHardware_withNoOtherLoaders_decodesResource() throws InterruptedException { assumeTrue( - "Hardware Bitmaps are only supported on O+", - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O); + "Hardware Bitmaps are only supported on P+", + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P); // enableHardwareBitmaps must be called on the main thread. final CountDownLatch latch = new CountDownLatch(1); Util.postOnUiThread( @@ -184,8 +191,8 @@ public void loadOpaqueGifResource_asBytes_decodesResource() { @Test public void loadOpaqueGifResource_asHardware_withNoOtherLoaders_decodesResource() { assumeTrue( - "Hardware Bitmaps are only supported on O+", - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O); + "Hardware Bitmaps are only supported on P+", + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P); Glide.get(context) .getRegistry() diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/LoadVideoResourceTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/LoadVideoResourceTest.java index 8935ed4452..f21fc790c1 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/LoadVideoResourceTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/LoadVideoResourceTest.java @@ -10,10 +10,10 @@ import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.GlideApp; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; import java.io.IOException; import java.util.concurrent.TimeUnit; import org.junit.Before; diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/MultiRequestTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/MultiRequestTest.java new file mode 100644 index 0000000000..cd5948b8ad --- /dev/null +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/MultiRequestTest.java @@ -0,0 +1,163 @@ +package com.bumptech.glide; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Bitmap.CompressFormat; +import android.graphics.Bitmap.Config; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.GlideException; +import com.bumptech.glide.load.engine.executor.GlideExecutor; +import com.bumptech.glide.request.Request; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.target.CustomTarget; +import com.bumptech.glide.request.target.Target; +import com.bumptech.glide.request.transition.Transition; +import com.bumptech.glide.test.ModelGeneratorRule; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class MultiRequestTest { + private final Context context = ApplicationProvider.getApplicationContext(); + @Rule public final TearDownGlide tearDownGlide = new TearDownGlide(); + @Rule public final ModelGeneratorRule modelGeneratorRule = new ModelGeneratorRule(); + @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + private final ConcurrencyHelper concurrency = new ConcurrencyHelper(); + + @Test + public void thumbnail_onResourceReady_forPrimary_isComplete_whenRequestListenerIsCalled() + throws IOException, InterruptedException { + + // Make sure the requests complete in the same order + Glide.init( + context, + new GlideBuilder() + .setSourceExecutor(GlideExecutor.newSourceBuilder().setThreadCount(1).build())); + + AtomicBoolean isPrimaryRequestComplete = new AtomicBoolean(false); + CountDownLatch countDownLatch = new CountDownLatch(1); + + RequestBuilder request = + Glide.with(context) + .load(newImageFile()) + .thumbnail(Glide.with(context).load(newImageFile())) + .listener( + new RequestListener<>() { + @Override + public boolean onLoadFailed( + @Nullable GlideException e, + Object model, + @NonNull Target target, + boolean isFirstResource) { + return false; + } + + @Override + public boolean onResourceReady( + @NonNull Drawable resource, + @NonNull Object model, + Target target, + @NonNull DataSource dataSource, + boolean isFirstResource) { + isPrimaryRequestComplete.set(target.getRequest().isComplete()); + countDownLatch.countDown(); + return false; + } + }); + concurrency.runOnMainThread(() -> request.into(new DoNothingTarget())); + + assertThat(countDownLatch.await(3, TimeUnit.SECONDS)).isTrue(); + assertThat(isPrimaryRequestComplete.get()).isTrue(); + } + + @Test + public void thumbnail_onLoadFailed_forPrimary_isNotRunningOrComplete_whenRequestListenerIsCalled() + throws IOException, InterruptedException { + + // Make sure the requests complete in the same order + Glide.init( + context, + new GlideBuilder() + .setSourceExecutor(GlideExecutor.newSourceBuilder().setThreadCount(1).build())); + + AtomicBoolean isNeitherRunningNorComplete = new AtomicBoolean(false); + CountDownLatch countDownLatch = new CountDownLatch(1); + + int missingResourceId = 123; + RequestBuilder requestBuilder = + Glide.with(context) + .load(missingResourceId) + .thumbnail(Glide.with(context).load(newImageFile())) + .listener( + new RequestListener<>() { + @Override + public boolean onLoadFailed( + @Nullable GlideException e, + Object model, + @NonNull Target target, + boolean isFirstResource) { + Request request = target.getRequest(); + isNeitherRunningNorComplete.set(!request.isComplete() && !request.isRunning()); + countDownLatch.countDown(); + return false; + } + + @Override + public boolean onResourceReady( + @NonNull Drawable resource, + @NonNull Object model, + Target target, + @NonNull DataSource dataSource, + boolean isFirstResource) { + return false; + } + }); + concurrency.runOnMainThread(() -> requestBuilder.into(new DoNothingTarget())); + + assertThat(countDownLatch.await(3, TimeUnit.SECONDS)).isTrue(); + assertThat(isNeitherRunningNorComplete.get()).isTrue(); + } + + private File newImageFile() throws IOException { + Bitmap bitmap = Bitmap.createBitmap(100, 100, Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + canvas.drawColor(Color.RED); + File result = temporaryFolder.newFile(); + try (OutputStream os = new BufferedOutputStream(new FileOutputStream(result))) { + bitmap.compress(CompressFormat.JPEG, 75, os); + } + return result; + } + + // We don't store or do anything with the resource, so we don't need to do anything to release it + // in onLoadCleared. + private static final class DoNothingTarget extends CustomTarget { + @Override + public void onResourceReady( + @NonNull Drawable resource, @Nullable Transition transition) {} + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) {} + } +} diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/NonBitmapDrawableResourcesTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/NonBitmapDrawableResourcesTest.java index 15cacbf857..4239cc677f 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/NonBitmapDrawableResourcesTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/NonBitmapDrawableResourcesTest.java @@ -20,31 +20,22 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import com.bumptech.glide.load.resource.bitmap.RoundedCorners; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.TearDownGlide; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.ExecutionException; -import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.function.ThrowingRunnable; import org.junit.rules.TestName; import org.junit.runner.RunWith; -import org.mockito.MockitoAnnotations; @RunWith(AndroidJUnit4.class) public class NonBitmapDrawableResourcesTest { @Rule public final TestName testName = new TestName(); @Rule public final TearDownGlide tearDownGlide = new TearDownGlide(); - - private Context context; - - @Before - public void setUp() { - MockitoAnnotations.initMocks(this); - context = ApplicationProvider.getApplicationContext(); - } + private final Context context = ApplicationProvider.getApplicationContext(); @Test public void load_withBitmapResourceId_asDrawable_producesNonNullDrawable() @@ -395,7 +386,7 @@ public void load_withApplicationIconResourceNameUri_asDrawable_producesNonNullDr for (String packageName : getInstalledPackages()) { int iconResourceId = getResourceId(packageName); - Context toUse = context.createPackageContext(packageName, /*flags=*/ 0); + Context toUse = context.createPackageContext(packageName, /* flags= */ 0); Resources resources = toUse.getResources(); Uri uri = new Uri.Builder() @@ -416,7 +407,7 @@ public void load_withApplicationIconResourceNameUri_asDrawable_withTransform_non for (String packageName : getInstalledPackages()) { int iconResourceId = getResourceId(packageName); - Context toUse = context.createPackageContext(packageName, /*flags=*/ 0); + Context toUse = context.createPackageContext(packageName, /* flags= */ 0); Resources resources = toUse.getResources(); Uri uri = new Uri.Builder() @@ -437,7 +428,7 @@ public void load_withApplicationIconResourceNameUri_asBitmap_producesNonNullBitm for (String packageName : getInstalledPackages()) { int iconResourceId = getResourceId(packageName); - Context toUse = context.createPackageContext(packageName, /*flags=*/ 0); + Context toUse = context.createPackageContext(packageName, /* flags= */ 0); Resources resources = toUse.getResources(); Uri uri = new Uri.Builder() @@ -458,7 +449,7 @@ public void load_withApplicationIconResourceNameUri_asBitmap_withTransform_nonNu for (String packageName : getInstalledPackages()) { int iconResourceId = getResourceId(packageName); - Context toUse = context.createPackageContext(packageName, /*flags=*/ 0); + Context toUse = context.createPackageContext(packageName, /* flags= */ 0); Resources resources = toUse.getResources(); Uri uri = new Uri.Builder() @@ -478,7 +469,8 @@ private Set getInstalledPackages() { Intent mainIntent = new Intent(Intent.ACTION_MAIN, null); mainIntent.addCategory(Intent.CATEGORY_LAUNCHER); PackageManager packageManager = context.getPackageManager(); - List pkgAppsList = packageManager.queryIntentActivities(mainIntent, /*flags=*/ 0); + List pkgAppsList = + packageManager.queryIntentActivities(mainIntent, /* flags= */ 0); Set result = new HashSet<>(); for (ResolveInfo info : pkgAppsList) { String packageName = info.activityInfo.packageName; @@ -494,7 +486,7 @@ && doesApplicationPackageNameMatchResourcePackageName(packageName, iconResourceI private int getResourceId(String packageName) { PackageInfo packageInfo; try { - packageInfo = context.getPackageManager().getPackageInfo(packageName, /*flags=*/ 0); + packageInfo = context.getPackageManager().getPackageInfo(packageName, /* flags= */ 0); } catch (NameNotFoundException e) { return 0; } @@ -536,7 +528,7 @@ private int getResourceId(String packageName) { private boolean doesApplicationPackageNameMatchResourcePackageName( String applicationPackageName, int iconResourceId) { try { - Context current = context.createPackageContext(applicationPackageName, /*flags=*/ 0); + Context current = context.createPackageContext(applicationPackageName, /* flags= */ 0); String resourcePackageName = current.getResources().getResourcePackageName(iconResourceId); return applicationPackageName.equals(resourcePackageName); } catch (NameNotFoundException e) { diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/PausedRequestsTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/PausedRequestsTest.java index 3706b53643..f086e743fc 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/PausedRequestsTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/PausedRequestsTest.java @@ -7,11 +7,11 @@ import android.graphics.drawable.ColorDrawable; import android.widget.ImageView; import androidx.test.core.app.ApplicationProvider; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.GlideApp; import com.bumptech.glide.test.GlideRequests; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; import org.junit.Rule; import org.junit.Test; diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/RequestManagerLifecycleTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/RequestManagerLifecycleTest.java new file mode 100644 index 0000000000..7e577fec98 --- /dev/null +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/RequestManagerLifecycleTest.java @@ -0,0 +1,538 @@ +package com.bumptech.glide; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.fail; +import static org.junit.Assume.assumeTrue; + +import android.os.Build; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.Lifecycle.Event; +import androidx.lifecycle.Lifecycle.State; +import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.OnLifecycleEvent; +import androidx.test.core.app.ActivityScenario; +import androidx.test.core.app.ActivityScenario.ActivityAction; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.bumptech.glide.instrumentation.R; +import com.bumptech.glide.test.DefaultFragmentActivity; +import com.bumptech.glide.testutil.TearDownGlide; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +// This test avoids using FragmentScenario because it doesn't seem to let us to get into the common +// created but not yet started state, only either before onCreateView or after onResume. +@RunWith(AndroidJUnit4.class) +public class RequestManagerLifecycleTest { + private static final String FRAGMENT_TAG = "fragment"; + private static final String FRAGMENT_SIBLING_TAG = "fragment_sibling"; + private static final String CHILD_FRAGMENT_TAG = "child"; + @Rule public final TearDownGlide tearDownGlide = new TearDownGlide(); + + @Rule + public final ActivityScenarioRule scenarioRule = + new ActivityScenarioRule<>(DefaultFragmentActivity.class); + + private ActivityScenario scenario; + + @Before + public void setUp() { + scenario = scenarioRule.getScenario(); + } + + @Test + public void get_twice_withSameActivity_returnsSameRequestManager() { + scenario.moveToState(State.CREATED); + scenario.onActivity( + activity -> assertThat(Glide.with(activity)).isEqualTo(Glide.with(activity))); + } + + @Test + public void get_withActivityBeforeCreate_startsRequestManager() { + scenario.moveToState(State.CREATED); + scenario.onActivity(activity -> assertThat(Glide.with(activity).isPaused()).isFalse()); + } + + // See b/262668610 + @SuppressWarnings("OnLifecycleEvent") + @Test + public void get_withActivityOnDestroy_QPlus_doesNotCrash() { + // Activity#isDestroyed's behavior seems to have changed in Q. On Q+, isDestroyed returns false + // during onDestroy, so we have to handle that case explicitly. + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + scenario.moveToState(State.CREATED); + + class GetOnDestroy implements LifecycleObserver { + private final FragmentActivity activity; + + GetOnDestroy(FragmentActivity activity) { + this.activity = activity; + } + + @OnLifecycleEvent(Event.ON_DESTROY) + public void onDestroy(@NonNull LifecycleOwner owner) { + Glide.with(activity); + } + } + scenario.onActivity( + activity -> activity.getLifecycle().addObserver(new GetOnDestroy(activity))); + scenario.moveToState(State.DESTROYED); + } + + @SuppressWarnings("OnLifecycleEvent") + @Test + public void get_withActivityOnDestroy_afterJellyBeanAndbeforeQ_doesNotCrash() { + // Activity#isDestroyed's behavior seems to have changed in Q. On Build.VERSION_CODES.JELLY_BEAN + && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q); + AtomicReference thrownException = new AtomicReference<>(); + scenario.moveToState(State.CREATED); + + class GetOnDestroy implements LifecycleObserver { + private final FragmentActivity activity; + + GetOnDestroy(FragmentActivity activity) { + this.activity = activity; + } + + @OnLifecycleEvent(Event.ON_DESTROY) + public void onDestroy(@NonNull LifecycleOwner owner) { + try { + Glide.with(activity); + fail("Failed to throw expected exception"); + } catch (Exception e) { + thrownException.set(e); + } + } + } + scenario.onActivity( + activity -> activity.getLifecycle().addObserver(new GetOnDestroy(activity))); + scenario.moveToState(State.DESTROYED); + + assertThat(thrownException.get()) + .hasMessageThat() + .contains("You cannot start a load for a destroyed activity"); + } + + @Test + public void get_withFragment_beforeFragmentIsAdded_throws() { + Fragment fragment = new Fragment(); + assertThrows(NullPointerException.class, () -> Glide.with(fragment)); + } + + @Test + public void get_withFragment_whenFragmentIsAddedAndVisible_beforeStart_startsRequestManager() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment fragment = getFragment(activity); + + assertThat(fragment.isVisible()).isTrue(); + assertThat(Glide.with(fragment).isPaused()).isFalse(); + }); + } + + @Test + public void requestManager_afterFragmentIsStopped_isPaused() { + // Avoid using FragmentScenario because it doesn't seem to let us to get into the common created + // but not yet started state, only either before onCreateView or after onResume. + final Fragment fragment = new EmptyContainerFragment(); + scenario.moveToState(State.RESUMED); + scenario.onActivity( + activity -> { + activity + .getSupportFragmentManager() + .beginTransaction() + .add(R.id.container, fragment) + .commitNowAllowingStateLoss(); + // If we call with() for the first time after the fragment is paused but while it's still + // visible, then we'll default the request manager to started. So we call with() once here + // to make sure the request manager is created before the stop event below. + Glide.with(fragment); + }); + + scenario.moveToState(State.CREATED); + scenario.onActivity( + activity -> { + assertThat(fragment.isVisible()).isTrue(); + assertThat(Glide.with(fragment).isPaused()).isTrue(); + }); + } + + @Test + public void get_twice_withSameFragment_returnsSameRequestManager() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment fragment = getFragment(activity); + assertThat(Glide.with(fragment)).isEqualTo(Glide.with(fragment)); + }); + } + + @Test + public void pauseRequestsRecursive_onActivity_pausesFragment() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment fragment = getFragment(activity); + + Glide.with(activity).pauseAllRequestsRecursive(); + assertThat(Glide.with(fragment).isPaused()).isTrue(); + }); + } + + @Test + public void resumeRequestsRecursive_onActivity_resumesFragment() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment fragment = getFragment(activity); + + Glide.with(activity).pauseAllRequestsRecursive(); + Glide.with(activity).resumeRequestsRecursive(); + + assertThat(Glide.with(fragment).isPaused()).isFalse(); + }); + } + + @Test + public void pauseRequestsRecursive_onActivity_pausesChildOfChildFragment() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment childFragment = getChildFragment(activity); + + Glide.with(activity).pauseAllRequestsRecursive(); + + assertThat(Glide.with(childFragment).isPaused()).isTrue(); + }); + } + + @Test + public void resumeRequestsRecursive_onActivity_resumesChildOfChildFragment() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment childFragment = getChildFragment(activity); + + Glide.with(activity).pauseAllRequestsRecursive(); + Glide.with(activity).resumeRequestsRecursive(); + + assertThat(Glide.with(childFragment).isPaused()).isFalse(); + }); + } + + @Test + public void pauseRequestsRecursive_onChildFragmentOfActivity_doesNotPauseActivity() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment fragment = getFragment(activity); + + Glide.with(fragment).pauseAllRequestsRecursive(); + + assertThat(Glide.with(fragment).isPaused()).isTrue(); + assertThat(Glide.with(activity).isPaused()).isFalse(); + }); + } + + @Test + public void pauseRequestsRecursive_onChildFragmentOfActivity_pausesChildOfChildFragment() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment parentFragment = getFragment(activity); + Fragment childFragment = getChildFragment(activity); + + Glide.with(parentFragment).pauseAllRequestsRecursive(); + + assertThat(Glide.with(childFragment).isPaused()).isTrue(); + }); + } + + @Test + public void resumeRequestsRecursive_onChildFragmentOfActivity_resumesChildOfChildFragment() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment parentFragment = getFragment(activity); + Fragment childFragment = getChildFragment(activity); + + Glide.with(parentFragment).pauseAllRequestsRecursive(); + Glide.with(parentFragment).resumeRequestsRecursive(); + + assertThat(Glide.with(childFragment).isPaused()).isFalse(); + }); + } + + @Test + public void pauseRequests_onActivity_pausesRequestManager() { + scenario.moveToState(State.RESUMED); + scenario.onActivity( + activity -> { + Glide.with(activity).pauseAllRequests(); + assertThat(Glide.with(activity).isPaused()).isTrue(); + }); + } + + @Test + public void resumeRequests_onActivity_pausesRequestManager() { + scenario.moveToState(State.RESUMED); + scenario.onActivity( + activity -> { + Glide.with(activity).pauseAllRequests(); + Glide.with(activity).resumeRequests(); + assertThat(Glide.with(activity).isPaused()).isFalse(); + }); + } + + @Test + public void pauseRequests_onActivity_doesNotPauseChildren() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment fragment = getFragment(activity); + initRequestManagers(activity, fragment); + + Glide.with(activity).pauseAllRequests(); + assertThat(Glide.with(fragment).isPaused()).isFalse(); + }); + } + + @Test + public void resumeRequests_onActivity_doesNotResumeChildren() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment fragment = getFragment(activity); + initRequestManagers(activity, fragment); + + Glide.with(activity).pauseAllRequests(); + Glide.with(fragment).pauseAllRequests(); + Glide.with(activity).resumeRequests(); + + assertThat(Glide.with(fragment).isPaused()).isTrue(); + }); + } + + @Test + public void pauseRequests_onFragment_pausesRequestManager() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment fragment = getFragment(activity); + Glide.with(fragment).pauseAllRequests(); + assertThat(Glide.with(fragment).isPaused()).isTrue(); + }); + } + + @Test + public void resumeRequests_onFragment_resumesRequestManager() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment fragment = getFragment(activity); + Glide.with(fragment).pauseAllRequests(); + Glide.with(fragment).resumeRequests(); + assertThat(Glide.with(fragment).isPaused()).isFalse(); + }); + } + + @Test + public void pauseRequests_onChildFragment_doesNotPauseParentFragment() { + withActivityFragmentAndChildFragment( + activity -> { + Glide.with(getChildFragment(activity)).pauseAllRequests(); + + assertThat(Glide.with(getFragment(activity)).isPaused()).isFalse(); + }); + } + + @Test + public void resumeRequests_onChildFragment_doesNotResumeParentFragment() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment parentFragment = getFragment(activity); + Fragment childFragment = getChildFragment(activity); + Glide.with(childFragment).pauseAllRequests(); + Glide.with(parentFragment).pauseAllRequests(); + Glide.with(childFragment).resumeRequests(); + + assertThat(Glide.with(parentFragment).isPaused()).isTrue(); + }); + } + + @Test + public void pauseRequests_onChildFragment_pausesChildFragment() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment childFragment = getChildFragment(activity); + Glide.with(childFragment).pauseAllRequests(); + + assertThat(Glide.with(childFragment).isPaused()).isTrue(); + }); + } + + @Test + public void resumeRequests_onChildFragment_resumesChildFragment() { + withActivityFragmentAndChildFragment( + activity -> { + Fragment childFragment = getChildFragment(activity); + Glide.with(childFragment).pauseAllRequests(); + Glide.with(childFragment).resumeRequests(); + + assertThat(Glide.with(childFragment).isPaused()).isFalse(); + }); + } + + @Test + public void pauseRequestsRecursive_onActivity_withTwoSiblingFragments_pausesBothSiblings() { + withActivityAndTwoFragmentSiblings( + activity -> { + Fragment fragment = getFragment(activity); + Fragment sibling = getSiblingFragment(activity); + + Glide.with(activity).pauseAllRequestsRecursive(); + + assertThat(Glide.with(fragment).isPaused()).isTrue(); + assertThat(Glide.with(sibling).isPaused()).isTrue(); + }); + } + + @Test + public void resumeRequestsRecursive_onActivity_withTwoSiblingFragments_resumesBothSiblings() { + withActivityAndTwoFragmentSiblings( + activity -> { + Fragment fragment = getFragment(activity); + Fragment sibling = getSiblingFragment(activity); + + Glide.with(activity).pauseAllRequestsRecursive(); + Glide.with(activity).resumeRequestsRecursive(); + + assertThat(Glide.with(fragment).isPaused()).isFalse(); + assertThat(Glide.with(sibling).isPaused()).isFalse(); + }); + } + + @Test + public void pauseRequestsRecursive_onFragment_withSibling_doesNotPauseSibling() { + withActivityAndTwoFragmentSiblings( + activity -> { + Fragment fragment = getFragment(activity); + Fragment sibling = getSiblingFragment(activity); + + Glide.with(fragment).pauseAllRequestsRecursive(); + + assertThat(Glide.with(sibling).isPaused()).isFalse(); + }); + } + + @Test + public void resumeRequestsRecursive_onFragment_withSibling_doesNotResumeSibling() { + withActivityAndTwoFragmentSiblings( + activity -> { + Fragment fragment = getFragment(activity); + Fragment sibling = getSiblingFragment(activity); + + Glide.with(fragment).pauseAllRequestsRecursive(); + Glide.with(sibling).pauseAllRequests(); + Glide.with(fragment).resumeRequestsRecursive(); + + assertThat(Glide.with(sibling).isPaused()).isTrue(); + }); + } + + // We need to create the RequestManager first, or else it will start in the paused state. + // TODO(judds): If the parent is explicitly paused, any children added after it's paused should + // probably default to paused when it's created? + private void initRequestManagers(FragmentActivity activity, Fragment... fragments) { + Glide.with(activity); + for (Fragment fragment : fragments) { + Glide.with(fragment); + } + } + + /** Creates the tree: Activity - Fragment - Fragment */ + private void withActivityAndTwoFragmentSiblings( + ActivityAction assertion) { + setupAndRunActivityAction( + activity -> { + Fragment parentFragment = createAndAddFragment(activity, FRAGMENT_TAG); + Fragment siblingFragment = createAndAddFragment(activity, FRAGMENT_SIBLING_TAG); + initRequestManagers(activity, parentFragment, siblingFragment); + }, + assertion); + } + + /** Creates the tree: Activity - Fragment - Child Fragment */ + private void withActivityFragmentAndChildFragment( + ActivityAction assertion) { + setupAndRunActivityAction( + activity -> { + Fragment parentFragment = createAndAddFragment(activity, FRAGMENT_TAG); + Fragment childFragment = createAndAddFragment(parentFragment, CHILD_FRAGMENT_TAG); + initRequestManagers(activity, parentFragment, childFragment); + }, + assertion); + } + + private void setupAndRunActivityAction( + ActivityAction setup, + ActivityAction assertion) { + scenario.moveToState(State.RESUMED); + // Using one onActivity call to do the test setup and another to assert gives the framework + // and Glide's fragment management code (onAttach in particular) the opportunity to run before + // our + // assertions take place. + scenario.onActivity(setup); + scenario.onActivity(assertion); + } + + private Fragment getFragment(FragmentActivity activity) { + return getFragment(activity, FRAGMENT_TAG); + } + + private Fragment getSiblingFragment(FragmentActivity activity) { + return getFragment(activity, FRAGMENT_SIBLING_TAG); + } + + private Fragment getChildFragment(FragmentActivity activity) { + return getFragment(getFragment(activity).getChildFragmentManager(), CHILD_FRAGMENT_TAG); + } + + private Fragment getFragment(FragmentActivity activity, String tag) { + return getFragment(activity.getSupportFragmentManager(), tag); + } + + private Fragment getFragment(FragmentManager manager, String tag) { + return manager.findFragmentByTag(tag); + } + + private Fragment createAndAddFragment(FragmentActivity parent, String tag) { + return createAndAddFragment(parent.getSupportFragmentManager(), tag); + } + + private Fragment createAndAddFragment(Fragment fragment, String tag) { + return createAndAddFragment(fragment.getChildFragmentManager(), tag); + } + + private Fragment createAndAddFragment(FragmentManager manager, String tag) { + Fragment result = new EmptyContainerFragment(); + manager.beginTransaction().add(R.id.container, result, tag).commitNowAllowingStateLoss(); + return result; + } + + public static final class EmptyContainerFragment extends Fragment { + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate( + R.layout.default_fragment_activity, container, /* attachToRoot= */ false); + } + } +} diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/RequestManagerTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/RequestManagerTest.java index e6d5908bf8..d7139f54f9 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/RequestManagerTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/RequestManagerTest.java @@ -1,36 +1,19 @@ package com.bumptech.glide; -import static com.google.common.truth.Truth.assertThat; - import android.content.Context; import android.graphics.drawable.Drawable; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; import android.widget.ImageView; import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.Lifecycle.State; -import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ActivityScenario.ActivityAction; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.bumptech.glide.manager.Lifecycle; import com.bumptech.glide.manager.LifecycleListener; -import com.bumptech.glide.manager.RequestManagerFragment; import com.bumptech.glide.manager.RequestManagerTreeNode; -import com.bumptech.glide.manager.SupportRequestManagerFragment; import com.bumptech.glide.request.target.Target; -import com.bumptech.glide.test.ConcurrencyHelper; -import com.bumptech.glide.test.GlideWithAsDifferentSupertypesActivity; -import com.bumptech.glide.test.GlideWithBeforeSuperOnCreateActivity; import com.bumptech.glide.test.ResourceIds; import com.bumptech.glide.test.ResourceIds.raw; -import com.bumptech.glide.test.TearDownGlide; -import com.google.common.collect.Iterables; -import java.util.ArrayList; -import java.util.List; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -113,111 +96,4 @@ public void run() { } }); } - - @Test - public void with_beforeActivitySuperOnCreate_onlyAddsOneRequestManagerFragment() { - ActivityScenario scenario = - ActivityScenario.launch(GlideWithBeforeSuperOnCreateActivity.class); - scenario.moveToState(State.RESUMED); - scenario.onActivity( - new ActivityAction() { - @Override - public void perform(GlideWithBeforeSuperOnCreateActivity activity) { - List fragments = activity.getSupportFragmentManager().getFragments(); - List glideFragments = new ArrayList<>(); - for (Fragment fragment : fragments) { - if (fragment instanceof SupportRequestManagerFragment) { - glideFragments.add(fragment); - } - } - // Ideally this would be exactly 1, but it's a bit tricky to implement. For now we're - // content making sure that we're not adding multiple fragments. - assertThat(glideFragments.size()).isAtMost(1); - } - }); - scenario.onActivity( - new ActivityAction() { - @Override - public void perform(final GlideWithBeforeSuperOnCreateActivity activity) { - new Handler(Looper.getMainLooper()) - .post( - new Runnable() { - @Override - public void run() { - Glide.with(activity); - } - }); - } - }); - scenario.onActivity( - new ActivityAction() { - @Override - public void perform(GlideWithBeforeSuperOnCreateActivity activity) { - List fragments = activity.getSupportFragmentManager().getFragments(); - List glideFragments = new ArrayList<>(); - for (Fragment fragment : fragments) { - if (fragment instanceof SupportRequestManagerFragment) { - glideFragments.add(fragment); - } - } - // Now that we've called Glide.with() after commitAllowingStateLoss will actually add - // the - // fragment, ie after onCreate, we can expect exactly one Fragment instance. - assertThat(glideFragments.size()).isEqualTo(1); - } - }); - } - - @Test - public void with_asDifferentSuperTypes_doesNotAddMultipleFragments() { - ActivityScenario scenario = - ActivityScenario.launch(GlideWithAsDifferentSupertypesActivity.class); - scenario.moveToState(State.RESUMED); - scenario.onActivity( - new ActivityAction() { - @Override - public void perform(GlideWithAsDifferentSupertypesActivity activity) { - Iterable glideSupportFragments = - Iterables.filter( - activity.getSupportFragmentManager().getFragments(), - SupportRequestManagerFragment.class); - Iterable normalFragments = - Iterables.filter( - getAllFragments(activity.getFragmentManager()), RequestManagerFragment.class); - assertThat(normalFragments).hasSize(0); - assertThat(glideSupportFragments).hasSize(1); - } - }); - } - - private List getAllFragments(android.app.FragmentManager fragmentManager) { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - ? fragmentManager.getFragments() - : getAllFragmentsPreO(fragmentManager); - } - - // Hacks based on the implementation of FragmentManagerImpl in the non-support libraries that - // allow us to iterate over and retrieve all active Fragments in a FragmentManager. - private static final String FRAGMENT_INDEX_KEY = "key"; - - private List getAllFragmentsPreO( - android.app.FragmentManager fragmentManager) { - Bundle tempBundle = new Bundle(); - int index = 0; - List result = new ArrayList<>(); - while (true) { - tempBundle.putInt(FRAGMENT_INDEX_KEY, index++); - android.app.Fragment fragment = null; - try { - fragment = fragmentManager.getFragment(tempBundle, FRAGMENT_INDEX_KEY); - } catch (Exception e) { - // This generates log spam from FragmentManager anyway. - } - if (fragment == null) { - break; - } - result.add(fragment); - } - return result; - } } diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/RequestTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/RequestTest.java index f93063b6dc..0a08ad363e 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/RequestTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/RequestTest.java @@ -1,7 +1,5 @@ package com.bumptech.glide; -import static com.bumptech.glide.test.Matchers.anyDrawable; -import static com.bumptech.glide.test.Matchers.anyDrawableTarget; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -17,16 +15,18 @@ import com.bumptech.glide.load.DataSource; import com.bumptech.glide.load.engine.executor.GlideExecutor; import com.bumptech.glide.request.RequestListener; -import com.bumptech.glide.test.ConcurrencyHelper; +import com.bumptech.glide.request.target.Target; import com.bumptech.glide.test.GlideApp; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; -import com.bumptech.glide.test.WaitModelLoader; -import com.bumptech.glide.test.WaitModelLoader.WaitModel; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; +import com.bumptech.glide.testutil.WaitModelLoader; +import com.bumptech.glide.testutil.WaitModelLoader.WaitModel; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -111,7 +111,7 @@ public void run() { @Test public void onStop_withSingleRequestInProgress_nullsOutDrawableInView() { - final WaitModel model = WaitModelLoader.Factory.waitOn(ResourceIds.raw.canonical); + final WaitModel model = WaitModelLoader.waitOn(ResourceIds.raw.canonical); concurrency.runOnMainThread( new Runnable() { @Override @@ -132,7 +132,7 @@ public void run() { @Test public void onStop_withRequestWithThumbnailBothInProgress_nullsOutDrawableInView() { - final WaitModel model = WaitModelLoader.Factory.waitOn(ResourceIds.raw.canonical); + final WaitModel model = WaitModelLoader.waitOn(ResourceIds.raw.canonical); concurrency.runOnMainThread( new Runnable() { @Override @@ -158,7 +158,7 @@ public void run() { /** Tests #2555. */ @Test public void clear_withRequestWithOnlyFullInProgress_nullsOutDrawableInView() { - final WaitModel mainModel = WaitModelLoader.Factory.waitOn(ResourceIds.raw.canonical); + final WaitModel mainModel = WaitModelLoader.waitOn(ResourceIds.raw.canonical); concurrency.loadUntilFirstFinish( GlideApp.with(context) .load(mainModel) @@ -180,16 +180,16 @@ public void run() { verify(requestListener, never()) .onResourceReady( - anyDrawable(), + ArgumentMatchers.any(), any(), - anyDrawableTarget(), + ArgumentMatchers.>any(), eq(DataSource.DATA_DISK_CACHE), anyBoolean()); verify(requestListener, never()) .onResourceReady( - anyDrawable(), + ArgumentMatchers.any(), any(), - anyDrawableTarget(), + ArgumentMatchers.>any(), eq(DataSource.RESOURCE_DISK_CACHE), anyBoolean()); assertThat(imageView.getDrawable()).isNull(); @@ -198,7 +198,7 @@ public void run() { @Test public void clear_withRequestWithOnlyFullInProgress_doesNotNullOutDrawableInView() { - final WaitModel mainModel = WaitModelLoader.Factory.waitOn(ResourceIds.raw.canonical); + final WaitModel mainModel = WaitModelLoader.waitOn(ResourceIds.raw.canonical); concurrency.loadUntilFirstFinish( GlideApp.with(context) .load(mainModel) @@ -220,16 +220,16 @@ public void run() { verify(requestListener, never()) .onResourceReady( - anyDrawable(), + ArgumentMatchers.any(), any(), - anyDrawableTarget(), + ArgumentMatchers.>any(), eq(DataSource.DATA_DISK_CACHE), anyBoolean()); verify(requestListener, never()) .onResourceReady( - anyDrawable(), + ArgumentMatchers.any(), any(), - anyDrawableTarget(), + ArgumentMatchers.>any(), eq(DataSource.RESOURCE_DISK_CACHE), anyBoolean()); assertThat(imageView.getDrawable()).isNotNull(); @@ -238,7 +238,7 @@ public void run() { @Test public void onStop_withRequestWithOnlyThumbnailInProgress_doesNotNullOutDrawableInView() { - final WaitModel thumbModel = WaitModelLoader.Factory.waitOn(ResourceIds.raw.canonical); + final WaitModel thumbModel = WaitModelLoader.waitOn(ResourceIds.raw.canonical); concurrency.loadUntilFirstFinish( GlideApp.with(context) .load(ResourceIds.raw.canonical) @@ -260,16 +260,16 @@ public void run() { verify(requestListener, never()) .onResourceReady( - anyDrawable(), + ArgumentMatchers.any(), any(), - anyDrawableTarget(), + ArgumentMatchers.>any(), eq(DataSource.DATA_DISK_CACHE), anyBoolean()); verify(requestListener, never()) .onResourceReady( - anyDrawable(), + ArgumentMatchers.any(), any(), - anyDrawableTarget(), + ArgumentMatchers.>any(), eq(DataSource.RESOURCE_DISK_CACHE), anyBoolean()); diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/RoundedCornersRegressionTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/RoundedCornersRegressionTest.java index 2b26f49506..4e9f63136d 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/RoundedCornersRegressionTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/RoundedCornersRegressionTest.java @@ -15,7 +15,7 @@ import com.bumptech.glide.test.RegressionTest; import com.bumptech.glide.test.SplitByCpu; import com.bumptech.glide.test.SplitBySdk; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.TearDownGlide; import java.util.concurrent.ExecutionException; import org.junit.Before; import org.junit.Rule; @@ -43,7 +43,8 @@ public class RoundedCornersRegressionTest { @Before public void setUp() throws Exception { context = ApplicationProvider.getApplicationContext(); - bitmapRegressionTester = new BitmapRegressionTester(getClass(), testName); + bitmapRegressionTester = + BitmapRegressionTester.newInstance(getClass(), testName).assumeShouldRun(); canonicalBitmap = new CanonicalBitmap(); } diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/WideGamutTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/WideGamutTest.java index c0dcb6e945..2fba7d8656 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/WideGamutTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/WideGamutTest.java @@ -16,10 +16,10 @@ import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool; import com.bumptech.glide.load.resource.bitmap.Downsampler; import com.bumptech.glide.load.resource.bitmap.RoundedCorners; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.GlideApp; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; import java.io.ByteArrayOutputStream; import org.junit.Before; import org.junit.Rule; @@ -82,7 +82,7 @@ public void load_withWideGamutImage_hardwareAllowed_returnsDecodedBitmap() { public void load_withEncodedPngWideGamutImage_decodesWideGamut() { Bitmap toCompress = Bitmap.createBitmap( - 100, 100, Bitmap.Config.RGBA_F16, /*hasAlpha=*/ true, ColorSpace.get(Named.DCI_P3)); + 100, 100, Bitmap.Config.RGBA_F16, /* hasAlpha= */ true, ColorSpace.get(Named.DCI_P3)); byte[] data = asPng(toCompress); @@ -97,7 +97,7 @@ public void load_withEncodedJpegWideGamutImage_decodesArgb8888() { assumeTrue(Build.VERSION.SDK_INT != Build.VERSION_CODES.O_MR1); Bitmap toCompress = Bitmap.createBitmap( - 100, 100, Bitmap.Config.RGBA_F16, /*hasAlpha=*/ true, ColorSpace.get(Named.DCI_P3)); + 100, 100, Bitmap.Config.RGBA_F16, /* hasAlpha= */ true, ColorSpace.get(Named.DCI_P3)); byte[] data = asJpeg(toCompress); @@ -109,7 +109,7 @@ public void load_withEncodedJpegWideGamutImage_decodesArgb8888() { public void load_withEncodedWebpWideGamutImage_decodesArgb8888() { Bitmap toCompress = Bitmap.createBitmap( - 100, 100, Bitmap.Config.RGBA_F16, /*hasAlpha=*/ true, ColorSpace.get(Named.DCI_P3)); + 100, 100, Bitmap.Config.RGBA_F16, /* hasAlpha= */ true, ColorSpace.get(Named.DCI_P3)); byte[] data = asWebp(toCompress); @@ -152,7 +152,7 @@ public void roundedCorners_withWideGamutBitmap_producesWideGamutBitmap() { GlideApp.with(context) .asBitmap() .load(data) - .transform(new RoundedCorners(/*roundingRadius=*/ 10)) + .transform(new RoundedCorners(/* roundingRadius= */ 10)) .submit()); assertThat(result).isNotNull(); assertThat(result.getConfig()).isEqualTo(Config.RGBA_F16); diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/load/engine/executor/IdlingGlideRule.java b/instrumentation/src/androidTest/java/com/bumptech/glide/load/engine/executor/IdlingGlideRule.java new file mode 100644 index 0000000000..fae2d2d157 --- /dev/null +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/load/engine/executor/IdlingGlideRule.java @@ -0,0 +1,79 @@ +package com.bumptech.glide.load.engine.executor; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.espresso.IdlingRegistry; +import androidx.test.espresso.idling.concurrent.IdlingThreadPoolExecutor; +import com.bumptech.glide.Glide; +import com.bumptech.glide.GlideBuilder; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.function.UnaryOperator; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + +/** Creates idling executors and registers them with espresso's {@link IdlingRegistry}. */ +public final class IdlingGlideRule implements TestRule { + + private final UnaryOperator additionalOptions; + + public static IdlingGlideRule newGlideRule(UnaryOperator additionalOptions) { + return new IdlingGlideRule(additionalOptions); + } + + private IdlingGlideRule(UnaryOperator additionalOptions) { + this.additionalOptions = additionalOptions; + } + + @Override + public Statement apply(Statement base, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + IdlingRegistry idlingRegistry = IdlingRegistry.getInstance(); + + IdlingThreadPoolExecutor sourceExecutor = + newIdlingThreadPoolExecutor( + GlideExecutor.DEFAULT_SOURCE_EXECUTOR_NAME, + GlideExecutor.calculateBestThreadCount()); + idlingRegistry.register(sourceExecutor); + IdlingThreadPoolExecutor diskCacheExecutor = + newIdlingThreadPoolExecutor( + GlideExecutor.DEFAULT_DISK_CACHE_EXECUTOR_NAME, + /* poolSize= */ GlideExecutor.DEFAULT_DISK_CACHE_EXECUTOR_THREADS); + idlingRegistry.register(diskCacheExecutor); + IdlingThreadPoolExecutor animationExecutor = + newIdlingThreadPoolExecutor( + GlideExecutor.DEFAULT_ANIMATION_EXECUTOR_NAME, + GlideExecutor.calculateAnimationExecutorThreadCount()); + idlingRegistry.register(animationExecutor); + try { + Glide.init( + ApplicationProvider.getApplicationContext(), + additionalOptions + .apply(new GlideBuilder()) + .setSourceExecutor(new GlideExecutor(sourceExecutor)) + .setDiskCacheExecutor(new GlideExecutor(diskCacheExecutor)) + .setAnimationExecutor(new GlideExecutor(animationExecutor))); + base.evaluate(); + } finally { + idlingRegistry.unregister(sourceExecutor); + idlingRegistry.unregister(diskCacheExecutor); + idlingRegistry.unregister(animationExecutor); + Glide.tearDown(); + } + } + }; + } + + private static IdlingThreadPoolExecutor newIdlingThreadPoolExecutor(String name, int poolSize) { + return new IdlingThreadPoolExecutor( + name, + /* corePoolSize= */ poolSize, + /* maximumPoolSize= */ poolSize, + /* keepAliveTime= */ 1, + TimeUnit.SECONDS, + new LinkedBlockingQueue<>(), + Thread::new); + } +} diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/load/resource/bitmap/DownsamplerEmulatorTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/load/resource/bitmap/DownsamplerEmulatorTest.java index deaceae8c3..b73f91d36a 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/load/resource/bitmap/DownsamplerEmulatorTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/load/resource/bitmap/DownsamplerEmulatorTest.java @@ -16,6 +16,7 @@ import android.graphics.Bitmap.CompressFormat; import android.graphics.Bitmap.Config; import android.os.Build; +import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; import android.util.DisplayMetrics; import androidx.annotation.Nullable; @@ -128,6 +129,26 @@ public void calculateScaling_withAtMost() throws IOException { .run(); } + @Test + public void calculateScaling_withGainmap_androidU_withAtMost() throws IOException { + new Tester(DownsampleStrategy.AT_MOST) + // See #3673 + .setTargetDimensions(1977, 2636) + .givenGainmapImageWithDimensionsOf( + 3024, + 4032, + /* allowHardwareConfig= */ false, + atAndAbove(34) + .with(new Formats(new CompressFormat[] {CompressFormat.JPEG}, 1512, 2016))) + .givenGainmapImageWithDimensionsOf( + 3024, + 4032, + /* allowHardwareConfig= */ true, + atAndAbove(34) + .with(new Formats(new CompressFormat[] {CompressFormat.JPEG}, 1512, 2016))) + .run(); + } + @Test public void calculateScaling_withAtLeast() throws IOException { new Tester(DownsampleStrategy.AT_LEAST) @@ -417,14 +438,18 @@ private static String runScaleTest( int targetWidth, int targetHeight, int exifOrientation, + boolean hasGainmap, + boolean allowHardwareConfig, DownsampleStrategy strategy, int expectedWidth, int expectedHeight) throws IOException { Downsampler downsampler = buildDownsampler(); - InputStream is = openBitmapStream(format, initialWidth, initialHeight, exifOrientation); + InputStream is = + openBitmapStream(format, initialWidth, initialHeight, exifOrientation, hasGainmap); Options options = new Options().set(DownsampleStrategy.OPTION, strategy); + options.set(Downsampler.ALLOW_HARDWARE_CONFIG, allowHardwareConfig); Bitmap bitmap; try { bitmap = downsampler.decode(is, targetWidth, targetHeight, options).get(); @@ -439,6 +464,8 @@ private static String runScaleTest( + strategy + ", orientation: " + exifOrientation + + ", allowHardwareConfig: " + + allowHardwareConfig + " -" + " Initial " + readableDimens(initialWidth, initialHeight) @@ -449,7 +476,35 @@ private static String runScaleTest( + " but threw OutOfMemoryError"; } try { - if (bitmap.getWidth() != expectedWidth || bitmap.getHeight() != expectedHeight) { + if (VERSION.SDK_INT >= VERSION_CODES.UPSIDE_DOWN_CAKE + && (bitmap.getWidth() != expectedWidth + || bitmap.getHeight() != expectedHeight + || bitmap.hasGainmap() != hasGainmap)) { + return "API: " + + Build.VERSION.SDK_INT + + ", os: " + + Build.VERSION.RELEASE + + ", format: " + + format + + ", strategy: " + + strategy + + ", orientation: " + + exifOrientation + + ", hasGainmap: " + + hasGainmap + + ", allowHardwareConfig: " + + allowHardwareConfig + + " -" + + " Initial " + + readableDimens(initialWidth, initialHeight) + + " Target " + + readableDimens(targetWidth, targetHeight) + + " Expected " + + readableDimensAndHasGainmap(expectedWidth, expectedHeight, hasGainmap) + + ", but Received " + + readableDimensAndHasGainmap( + bitmap.getWidth(), bitmap.getHeight(), bitmap.hasGainmap()); + } else if (bitmap.getWidth() != expectedWidth || bitmap.getHeight() != expectedHeight) { return "API: " + Build.VERSION.SDK_INT + ", os: " @@ -460,6 +515,8 @@ private static String runScaleTest( + strategy + ", orientation: " + exifOrientation + + ", allowHardwareConfig: " + + allowHardwareConfig + " -" + " Initial " + readableDimens(initialWidth, initialHeight) @@ -480,6 +537,10 @@ private static String readableDimens(int width, int height) { return "[" + width + "x" + height + "]"; } + private static String readableDimensAndHasGainmap(int width, int height, boolean hasGainmap) { + return "[" + width + "x" + height + "], hasGainmap=" + hasGainmap; + } + private static Downsampler buildDownsampler() { List parsers = Collections.singletonList(new DefaultImageHeaderParser()); @@ -492,7 +553,7 @@ private static Downsampler buildDownsampler() { } private static InputStream openBitmapStream( - CompressFormat format, int width, int height, int exifOrientation) { + CompressFormat format, int width, int height, int exifOrientation, boolean hasGainmap) { Preconditions.checkArgument( format == CompressFormat.JPEG || exifOrientation == ExifInterface.ORIENTATION_UNDEFINED, "Can only orient JPEGs, but asked for orientation: " @@ -502,13 +563,14 @@ private static InputStream openBitmapStream( // TODO: support orientations for formats other than JPEG. if (format == CompressFormat.JPEG) { - return openFileStream(width, height, exifOrientation); + return openFileStream(width, height, exifOrientation, hasGainmap); } else { - return openInMemoryStream(format, width, height); + return openInMemoryStream(format, width, height, hasGainmap); } } - private static InputStream openFileStream(int width, int height, int exifOrientation) { + private static InputStream openFileStream( + int width, int height, int exifOrientation, boolean hasGainmap) { int rotationDegrees = TransformationUtils.getExifOrientationDegrees(exifOrientation); if (rotationDegrees == 270 || rotationDegrees == 90) { int temp = width; @@ -517,16 +579,23 @@ private static InputStream openFileStream(int width, int height, int exifOrienta } Bitmap bitmap = Bitmap.createBitmap(width, height, Config.ARGB_8888); + if (hasGainmap && VERSION.SDK_INT >= VERSION_CODES.UPSIDE_DOWN_CAKE) { + bitmap.setGainmap( + // Intentionally not directly imported due to test failures with class resolution when + // running on SDK levels < 34. Also, do not extract methods with Gainmap in the method + // signature for the same reason. + new android.graphics.Gainmap(Bitmap.createBitmap(width / 2, height / 2, Config.ALPHA_8))); + } OutputStream os = null; try { File tempFile = File.createTempFile( - "ds-" + width + "-" + height + "-" + exifOrientation, + "ds-" + width + "-" + height + "-" + exifOrientation + "-" + hasGainmap, ".jpeg", ApplicationProvider.getApplicationContext().getCacheDir()); os = new BufferedOutputStream(new FileOutputStream(tempFile)); - bitmap.compress(CompressFormat.JPEG, /*quality=*/ 100, os); + bitmap.compress(CompressFormat.JPEG, /* quality= */ 100, os); bitmap.recycle(); os.close(); @@ -551,8 +620,16 @@ private static InputStream openFileStream(int width, int height, int exifOrienta } } - private static InputStream openInMemoryStream(CompressFormat format, int width, int height) { + private static InputStream openInMemoryStream( + CompressFormat format, int width, int height, boolean hasGainmap) { Bitmap bitmap = Bitmap.createBitmap(width, height, Config.ARGB_8888); + if (hasGainmap && VERSION.SDK_INT >= VERSION_CODES.UPSIDE_DOWN_CAKE) { + // Intentionally not directly imported due to test failures with class resolution when + // running on SDK levels < 34. Also, do not extract methods with Gainmap in the method + // signature for the same reason. + bitmap.setGainmap( + new android.graphics.Gainmap(Bitmap.createBitmap(width / 2, height / 2, Config.ALPHA_8))); + } ByteArrayOutputStream os = new ByteArrayOutputStream(); bitmap.compress(format, 100 /*quality*/, os); bitmap.recycle(); @@ -581,6 +658,21 @@ Tester givenSquareImageWithDimensionOf(int dimension, Api... apis) { return givenImageWithDimensionsOf(dimension, dimension, apis); } + Tester givenGainmapImageWithDimensionsOf( + int sourceWidth, int sourceHeight, boolean allowHardwareConfig, Api... apis) { + testCases.add( + new TestCase.Builder() + .setSourceWidth(sourceWidth) + .setSourceHeight(sourceHeight) + .setTargetWidth(targetWidth) + .setTargetHeight(targetHeight) + .setHasGainmap(true) + .setAllowHardwareConfig(allowHardwareConfig) + .setApis(apis) + .build()); + return this; + } + Tester givenImageWithDimensionsOf(int sourceWidth, int sourceHeight, Api... apis) { testCases.add(new TestCase(sourceWidth, sourceHeight, targetWidth, targetHeight, apis)); return this; @@ -608,23 +700,100 @@ private static final class TestCase { private final int sourceHeight; private final int targetWidth; private final int targetHeight; + private final boolean hasGainmap; + private final boolean allowHardwareConfig; private final Api[] apis; + /** + * @deprecated Use the {@link Builder}. + */ + @Deprecated TestCase(int sourceWidth, int sourceHeight, int targetWidth, int targetHeight, Api... apis) { this.sourceWidth = sourceWidth; this.sourceHeight = sourceHeight; this.targetWidth = targetWidth; this.targetHeight = targetHeight; + this.hasGainmap = false; + this.allowHardwareConfig = false; this.apis = apis; } + private TestCase(Builder builder) { + this.sourceWidth = builder.sourceWidth; + this.sourceHeight = builder.sourceHeight; + this.targetWidth = builder.targetWidth; + this.targetHeight = builder.targetHeight; + this.hasGainmap = builder.hasGainmap; + this.allowHardwareConfig = builder.allowHardwareConfig; + this.apis = builder.apis; + } + List test(DownsampleStrategy strategy) throws IOException { List results = new ArrayList<>(); for (Api api : apis) { - results.addAll(api.test(sourceWidth, sourceHeight, targetWidth, targetHeight, strategy)); + results.addAll( + api.test( + sourceWidth, + sourceHeight, + hasGainmap, + allowHardwareConfig, + targetWidth, + targetHeight, + strategy)); } return results; } + + private static final class Builder { + + private int sourceWidth; + private int sourceHeight; + private int targetWidth; + private int targetHeight; + private boolean hasGainmap; + private boolean allowHardwareConfig; + @Nullable private Api[] apis; + + public Builder setSourceWidth(int sourceWidth) { + this.sourceWidth = sourceWidth; + return this; + } + + public Builder setSourceHeight(int sourceHeight) { + this.sourceHeight = sourceHeight; + return this; + } + + public Builder setTargetWidth(int targetWidth) { + this.targetWidth = targetWidth; + return this; + } + + public Builder setTargetHeight(int targetHeight) { + this.targetHeight = targetHeight; + return this; + } + + public Builder setHasGainmap(boolean hasGainmap) { + this.hasGainmap = hasGainmap; + return this; + } + + public Builder setAllowHardwareConfig(boolean allowHardwareConfig) { + this.allowHardwareConfig = allowHardwareConfig; + return this; + } + + public Builder setApis(Api[] apis) { + this.apis = apis; + return this; + } + + public TestCase build() { + Preconditions.checkNotNull(apis); + return new TestCase(this); + } + } } } @@ -682,6 +851,8 @@ Api with(Formats... formats) { List test( int sourceWidth, int sourceHeight, + boolean hasGainmap, + boolean allowHardwareConfig, int targetWidth, int targetHeight, DownsampleStrategy strategy) @@ -693,7 +864,14 @@ List test( List results = new ArrayList<>(); for (Formats format : formats) { results.addAll( - format.runTest(sourceWidth, sourceHeight, targetWidth, targetHeight, strategy)); + format.runTest( + sourceWidth, + sourceHeight, + hasGainmap, + allowHardwareConfig, + targetWidth, + targetHeight, + strategy)); } return results; } @@ -747,6 +925,8 @@ Formats expect(int width, int height) { List runTest( int sourceWidth, int sourceHeight, + boolean hasGainmap, + boolean allowHardwareConfig, int targetWidth, int targetHeight, DownsampleStrategy strategy) @@ -764,6 +944,8 @@ List runTest( targetWidth, targetHeight, exifOrientation, + hasGainmap, + allowHardwareConfig, strategy, expectedWidth, expectedHeight); diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/load/resource/gif/GifDrawableTest.java b/instrumentation/src/androidTest/java/com/bumptech/glide/load/resource/gif/GifDrawableTest.java index 2c45598091..0d34b6d910 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/load/resource/gif/GifDrawableTest.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/load/resource/gif/GifDrawableTest.java @@ -15,10 +15,10 @@ import com.bumptech.glide.load.resource.gif.GifDrawable.GifState; import com.bumptech.glide.load.resource.gif.GifFrameLoader.OnEveryFrameListener; import com.bumptech.glide.request.target.Target; -import com.bumptech.glide.test.ConcurrencyHelper; import com.bumptech.glide.test.GlideApp; import com.bumptech.glide.test.ResourceIds; -import com.bumptech.glide.test.TearDownGlide; +import com.bumptech.glide.testutil.ConcurrencyHelper; +import com.bumptech.glide.testutil.TearDownGlide; import com.bumptech.glide.util.Preconditions; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/test/BitmapRegressionTester.java b/instrumentation/src/androidTest/java/com/bumptech/glide/test/BitmapRegressionTester.java index 702698c7fc..26bcd7ef41 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/test/BitmapRegressionTester.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/test/BitmapRegressionTester.java @@ -1,5 +1,8 @@ package com.bumptech.glide.test; +import static com.bumptech.glide.testutil.BitmapSubject.assertThat; +import static org.junit.Assume.assumeTrue; + import android.content.Context; import android.graphics.Bitmap; import android.graphics.Bitmap.CompressFormat; @@ -7,7 +10,7 @@ import android.os.Build; import android.os.Environment; import androidx.annotation.Nullable; -import androidx.test.InstrumentationRegistry; +import androidx.test.core.app.ApplicationProvider; import com.bumptech.glide.RequestBuilder; import java.io.BufferedOutputStream; import java.io.File; @@ -39,11 +42,17 @@ public final class BitmapRegressionTester { private static final String GENERATED_FILES_DIR = "test_files"; private static final String SEPARATOR = "_"; + private static final int RESOURCE_ID_NOT_FOUND = 0; + private final Class testClass; private final TestName testName; - private final Context context = InstrumentationRegistry.getTargetContext(); + private final Context context = ApplicationProvider.getApplicationContext(); + + public static AssumeCanRun newInstance(Class testClass, TestName testName) { + return new AssumeCanRun(new BitmapRegressionTester(testClass, testName)); + } - public BitmapRegressionTester(Class testClass, TestName testName) { + private BitmapRegressionTester(Class testClass, TestName testName) { this.testClass = testClass; this.testName = testName; @@ -53,6 +62,21 @@ public BitmapRegressionTester(Class testClass, TestName testName) { } } + public static final class AssumeCanRun { + + private final BitmapRegressionTester regressionTester; + + private AssumeCanRun(BitmapRegressionTester regressionTester) { + this.regressionTester = regressionTester; + } + + public BitmapRegressionTester assumeShouldRun() { + boolean shouldRun = regressionTester.shouldRun(); + assumeTrue(shouldRun); + return regressionTester; + } + } + public Bitmap test(RequestBuilder request) throws ExecutionException, InterruptedException { Bitmap result = request.submit().get(); @@ -60,7 +84,7 @@ public Bitmap test(RequestBuilder request) writeBitmap(result); } Bitmap expected = decodeExpected(); - BitmapSubject.assertThat(result).sameAs(expected); + assertThat(result).sameAs(expected); return result; } @@ -99,7 +123,6 @@ private SplitBySdk getSplitBySdkValues() { return result; } - @SuppressWarnings("deprecation") private String getCpuString() { return splitByCpu() ? SEPARATOR + Build.CPU_ABI.replace("-", "_") : ""; } @@ -150,7 +173,7 @@ private void writeBitmap(Bitmap bitmap) { OutputStream os = null; try { os = new BufferedOutputStream(new FileOutputStream(file)); - bitmap.compress(CompressFormat.PNG, /*quality=*/ 100, os); + bitmap.compress(CompressFormat.PNG, /* quality= */ 100, os); os.close(); } catch (IOException e) { throw new RuntimeException(e); @@ -165,17 +188,24 @@ private void writeBitmap(Bitmap bitmap) { } } + private boolean shouldRun() { + return writeNewExpected() || getResourceId() != RESOURCE_ID_NOT_FOUND; + } + private boolean writeNewExpected() { File testFiles = getTestFilesDir(); return new File(testFiles, REGENERATE_SIGNAL_FILE_NAME).exists(); } + private int getResourceId() { + return context + .getResources() + .getIdentifier(getResourceName(), RESOURCE_TYPE, context.getPackageName()); + } + private Bitmap decodeExpected() { - int resourceId = - context - .getResources() - .getIdentifier(getResourceName(), RESOURCE_TYPE, context.getPackageName()); - if (resourceId == 0) { + int resourceId = getResourceId(); + if (resourceId == RESOURCE_ID_NOT_FOUND) { throw new IllegalArgumentException( "Failed to find resource for: " + getResourceName() diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/test/CanonicalBitmap.java b/instrumentation/src/androidTest/java/com/bumptech/glide/test/CanonicalBitmap.java index e203e5d772..72da90edef 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/test/CanonicalBitmap.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/test/CanonicalBitmap.java @@ -5,7 +5,7 @@ import android.graphics.BitmapFactory; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.test.InstrumentationRegistry; +import androidx.test.core.app.ApplicationProvider; import com.bumptech.glide.util.Preconditions; public final class CanonicalBitmap { @@ -35,7 +35,7 @@ public int getHeight() { } private Bitmap decodeBitmap() { - Context context = InstrumentationRegistry.getTargetContext(); + Context context = ApplicationProvider.getApplicationContext(); BitmapFactory.Options options = new BitmapFactory.Options(); options.inScaled = false; int resourceId = ResourceIds.raw.canonical; @@ -46,7 +46,7 @@ private Bitmap decodeBitmap() { result, (int) (result.getWidth() * scaleFactor), (int) (result.getHeight() * scaleFactor), - /*filter=*/ false); + /* filter= */ false); } // Make sure the Bitmap is immutable. return result; diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/test/Matchers.java b/instrumentation/src/androidTest/java/com/bumptech/glide/test/Matchers.java deleted file mode 100644 index fb27096999..0000000000 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/test/Matchers.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.bumptech.glide.test; - -import static org.mockito.ArgumentMatchers.any; - -import android.graphics.Bitmap; -import android.graphics.drawable.Drawable; -import com.bumptech.glide.request.target.Target; - -/** Mockito matchers for various common classes. */ -public final class Matchers { - - private Matchers() { - // Utility class. - } - - public static Target anyDrawableTarget() { - return anyTarget(); - } - - public static Target anyBitmapTarget() { - return anyTarget(); - } - - @SuppressWarnings("unchecked") - public static Target anyTarget() { - return (Target) any(Target.class); - } - - public static Bitmap anyBitmap() { - return any(); - } - - public static Drawable anyDrawable() { - return any(); - } -} diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/test/MockModelLoader.java b/instrumentation/src/androidTest/java/com/bumptech/glide/test/MockModelLoader.java deleted file mode 100644 index 054ed0a0e1..0000000000 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/test/MockModelLoader.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.bumptech.glide.test; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.test.InstrumentationRegistry; -import com.bumptech.glide.Glide; -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; - -public final class MockModelLoader implements ModelLoader { - private final ModelT model; - private final DataT data; - - @SuppressWarnings("unchecked") - public static void mock(final ModelT model, final DataT data) { - Context context = InstrumentationRegistry.getTargetContext(); - - Glide.get(context) - .getRegistry() - .replace( - (Class) model.getClass(), - (Class) data.getClass(), - new ModelLoaderFactory() { - @NonNull - @Override - public ModelLoader build( - @NonNull MultiModelLoaderFactory multiFactory) { - return new MockModelLoader<>(model, data); - } - - @Override - public void teardown() { - // Do nothing. - } - }); - } - - private MockModelLoader(ModelT model, DataT data) { - this.model = model; - this.data = data; - } - - @Override - public LoadData buildLoadData( - @NonNull ModelT modelT, int width, int height, @NonNull Options options) { - return new LoadData<>(new ObjectKey(modelT), new MockDataFetcher<>(data)); - } - - @Override - public boolean handles(@NonNull ModelT model) { - return this.model.equals(model); - } - - private static final class MockDataFetcher implements DataFetcher { - - private final DataT data; - - MockDataFetcher(DataT data) { - this.data = data; - } - - @Override - public void loadData( - @NonNull Priority priority, @NonNull DataCallback callback) { - callback.onDataReady(data); - } - - @Override - public void cleanup() { - // Do nothing. - } - - @Override - public void cancel() { - // Do nothing. - } - - @NonNull - @Override - @SuppressWarnings("unchecked") - public Class getDataClass() { - return (Class) data.getClass(); - } - - @NonNull - @Override - public DataSource getDataSource() { - return DataSource.REMOTE; - } - } -} diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/test/ModelGeneratorRule.java b/instrumentation/src/androidTest/java/com/bumptech/glide/test/ModelGeneratorRule.java new file mode 100644 index 0000000000..d33a02942f --- /dev/null +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/test/ModelGeneratorRule.java @@ -0,0 +1,78 @@ +package com.bumptech.glide.test; + +import android.content.Context; +import android.content.res.Resources; +import androidx.annotation.RawRes; +import androidx.test.core.app.ApplicationProvider; +import com.google.common.io.ByteStreams; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.rules.ExternalResource; + +/** Converts raw resources into specific model types (Uris, Files, byte arrays etc). */ +public final class ModelGeneratorRule extends ExternalResource { + private static final String TEMP_FOLDER_NAME = "model_generator_rule_cache"; + + private final Context context = ApplicationProvider.getApplicationContext(); + private final AtomicInteger fileNameCounter = new AtomicInteger(); + + private File getTempDir() { + File tempDir = new File(context.getCacheDir(), TEMP_FOLDER_NAME); + if (!tempDir.mkdirs() && (!tempDir.exists() || !tempDir.isDirectory())) { + throw new IllegalStateException("Failed to mkdirs for: " + tempDir); + } + return tempDir; + } + + private File nextTempFile() { + String name = "model_generator" + fileNameCounter.getAndIncrement(); + return new File(getTempDir(), name); + } + + public File asFile(@RawRes int resourceId) throws IOException { + return writeToFile(resourceId); + } + + public byte[] asByteArray(@RawRes int resourceId) throws IOException { + Resources resources = context.getResources(); + InputStream is = resources.openRawResource(resourceId); + return ByteStreams.toByteArray(is); + } + + private File writeToFile(@RawRes int resourceId) throws IOException { + byte[] data = asByteArray(resourceId); + File result = nextTempFile(); + try (OutputStream os = new FileOutputStream(result)) { + os.write(data); + } + return result; + } + + @Override + protected void after() { + super.after(); + cleanupTempDir(); + } + + private void cleanupTempDir() { + File tempDir = getTempDir(); + File[] children = tempDir.listFiles(); + if (children != null) { + for (File child : children) { + if (child.isDirectory()) { + throw new IllegalStateException("Expected a file, but was a directory: " + child); + } + if (!child.delete()) { + throw new IllegalStateException("Failed to delete: " + child); + } + } + } + if (!tempDir.delete()) { + throw new IllegalStateException("Failed to delete temp dir: " + tempDir); + } + } +} diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/test/ResourceIds.java b/instrumentation/src/androidTest/java/com/bumptech/glide/test/ResourceIds.java index b4025ab0fa..86b266da86 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/test/ResourceIds.java +++ b/instrumentation/src/androidTest/java/com/bumptech/glide/test/ResourceIds.java @@ -25,6 +25,8 @@ public interface raw { int opaque_interlaced_gif = getResourceId("raw", "opaque_interlaced_gif"); int webkit_logo_p3 = getResourceId("raw", "webkit_logo_p3"); int video = getResourceId("raw", "video"); + int animated_webp = getResourceId("raw", "dl_world_anim_webp"); + int animated_avif = getResourceId("raw", "dl_world_anim_avif"); } public interface drawable { diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/test/WaitModelLoader.java b/instrumentation/src/androidTest/java/com/bumptech/glide/test/WaitModelLoader.java deleted file mode 100644 index 07dd3efabb..0000000000 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/test/WaitModelLoader.java +++ /dev/null @@ -1,136 +0,0 @@ -package com.bumptech.glide.test; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.test.InstrumentationRegistry; -import com.bumptech.glide.Glide; -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.test.WaitModelLoader.WaitModel; -import java.io.InputStream; -import java.util.concurrent.CountDownLatch; - -/** - * Allows callers to load an object but force the load to pause until {@link WaitModel#countDown()} - * is called. - */ -public final class WaitModelLoader implements ModelLoader, Data> { - - private final ModelLoader wrapped; - - private WaitModelLoader(ModelLoader wrapped) { - this.wrapped = wrapped; - } - - @Nullable - @Override - public LoadData buildLoadData( - @NonNull WaitModel waitModel, int width, int height, @NonNull Options options) { - LoadData wrappedLoadData = - wrapped.buildLoadData(waitModel.wrapped, width, height, options); - if (wrappedLoadData == null) { - return null; - } - return new LoadData<>( - wrappedLoadData.sourceKey, new WaitFetcher<>(wrappedLoadData.fetcher, waitModel.latch)); - } - - @Override - public boolean handles(@NonNull WaitModel waitModel) { - return wrapped.handles(waitModel.wrapped); - } - - public static final class WaitModel { - private final CountDownLatch latch = new CountDownLatch(1); - private final T wrapped; - - WaitModel(T wrapped) { - this.wrapped = wrapped; - } - - public void countDown() { - if (latch.getCount() != 1) { - throw new IllegalStateException(); - } - latch.countDown(); - } - } - - public static final class Factory - implements ModelLoaderFactory, Data> { - - private final Class modelClass; - private final Class dataClass; - - Factory(Class modelClass, Class dataClass) { - this.modelClass = modelClass; - this.dataClass = dataClass; - } - - public static synchronized WaitModel waitOn(T model) { - @SuppressWarnings("unchecked") - ModelLoaderFactory, InputStream> streamFactory = - new Factory<>((Class) model.getClass(), InputStream.class); - Glide.get(InstrumentationRegistry.getTargetContext()) - .getRegistry() - .replace(WaitModel.class, InputStream.class, streamFactory); - - return new WaitModel<>(model); - } - - @NonNull - @Override - public ModelLoader, Data> build(MultiModelLoaderFactory multiFactory) { - return new WaitModelLoader<>(multiFactory.build(modelClass, dataClass)); - } - - @Override - public void teardown() { - // Do nothing. - } - } - - private static final class WaitFetcher implements DataFetcher { - - private final DataFetcher wrapped; - private final CountDownLatch toWaitOn; - - WaitFetcher(DataFetcher wrapped, CountDownLatch toWaitOn) { - this.wrapped = wrapped; - this.toWaitOn = toWaitOn; - } - - @Override - public void loadData(@NonNull Priority priority, @NonNull DataCallback callback) { - ConcurrencyHelper.waitOnLatch(toWaitOn); - wrapped.loadData(priority, callback); - } - - @Override - public void cleanup() { - wrapped.cleanup(); - } - - @Override - public void cancel() { - wrapped.cancel(); - } - - @NonNull - @Override - public Class getDataClass() { - return wrapped.getDataClass(); - } - - @NonNull - @Override - public DataSource getDataSource() { - return wrapped.getDataSource(); - } - } -} diff --git a/instrumentation/src/main/AndroidManifest.xml b/instrumentation/src/main/AndroidManifest.xml index 894db86e43..8865c5eff0 100644 --- a/instrumentation/src/main/AndroidManifest.xml +++ b/instrumentation/src/main/AndroidManifest.xml @@ -1,9 +1,23 @@ + + xmlns:tools="http://schemas.android.com/tools"> + - - - + + + + + diff --git a/instrumentation/src/main/assets/canonical.jpg b/instrumentation/src/main/assets/canonical.jpg new file mode 100644 index 0000000000..889ba27624 Binary files /dev/null and b/instrumentation/src/main/assets/canonical.jpg differ diff --git a/instrumentation/src/main/assets/video.mp4 b/instrumentation/src/main/assets/video.mp4 new file mode 100644 index 0000000000..3ffc91a988 Binary files /dev/null and b/instrumentation/src/main/assets/video.mp4 differ diff --git a/instrumentation/src/main/java/com/bumptech/glide/test/DefaultFragmentActivity.java b/instrumentation/src/main/java/com/bumptech/glide/test/DefaultFragmentActivity.java new file mode 100644 index 0000000000..8b63a69f2b --- /dev/null +++ b/instrumentation/src/main/java/com/bumptech/glide/test/DefaultFragmentActivity.java @@ -0,0 +1,15 @@ +package com.bumptech.glide.test; + +import android.os.Bundle; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; +import com.bumptech.glide.instrumentation.R; + +public class DefaultFragmentActivity extends FragmentActivity { + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.default_fragment_activity); + } +} diff --git a/instrumentation/src/main/java/com/bumptech/glide/test/ForceDarkOrLightModeActivity.java b/instrumentation/src/main/java/com/bumptech/glide/test/ForceDarkOrLightModeActivity.java new file mode 100644 index 0000000000..ebd83d6b58 --- /dev/null +++ b/instrumentation/src/main/java/com/bumptech/glide/test/ForceDarkOrLightModeActivity.java @@ -0,0 +1,41 @@ +package com.bumptech.glide.test; + +import android.content.Context; +import android.content.Intent; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AppCompatDelegate; +import com.bumptech.glide.instrumentation.R; +import com.bumptech.glide.util.Preconditions; + +public class ForceDarkOrLightModeActivity extends AppCompatActivity { + private static final int INVALID_MODE = -1; + private static final String ARGS_NIGHT_MODE = "args_night_mode"; + + public static Intent forceLightMode(Context context) { + return newArgs(context, AppCompatDelegate.MODE_NIGHT_NO); + } + + public static Intent forceDarkMode(Context context) { + return newArgs(context, AppCompatDelegate.MODE_NIGHT_YES); + } + + private static Intent newArgs(Context context, int nightMode) { + Intent intent = new Intent(context, ForceDarkOrLightModeActivity.class); + intent.putExtra(ARGS_NIGHT_MODE, nightMode); + return intent; + } + + @RequiresApi(api = VERSION_CODES.JELLY_BEAN_MR1) + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + int modeToForce = getIntent().getExtras().getInt(ARGS_NIGHT_MODE, INVALID_MODE); + Preconditions.checkArgument(modeToForce != INVALID_MODE, "Invalid mode: " + modeToForce); + getDelegate().setLocalNightMode(modeToForce); + setContentView(R.layout.default_fragment_activity); + } +} diff --git a/instrumentation/src/main/res/drawable-night/dog.jpg b/instrumentation/src/main/res/drawable-night/dog.jpg new file mode 100644 index 0000000000..c91c8b2d17 Binary files /dev/null and b/instrumentation/src/main/res/drawable-night/dog.jpg differ diff --git a/instrumentation/src/main/res/drawable/dog.jpg b/instrumentation/src/main/res/drawable/dog.jpg new file mode 100644 index 0000000000..889ba27624 Binary files /dev/null and b/instrumentation/src/main/res/drawable/dog.jpg differ diff --git a/instrumentation/src/main/res/drawable/vector_drawable.xml b/instrumentation/src/main/res/drawable/vector_drawable.xml index 1732bb9ba5..aeaf7cc2d7 100644 --- a/instrumentation/src/main/res/drawable/vector_drawable.xml +++ b/instrumentation/src/main/res/drawable/vector_drawable.xml @@ -1,11 +1,12 @@ + -3.5,-3.5s1.6,-3.5 3.5,-3.5 3.5,1.6 3.5,3.5 -1.6,3.5 -3.5,3.5z" + tools:ignore="VectorRaster" /> diff --git a/instrumentation/src/main/res/drawable/vector_drawable_dark.xml b/instrumentation/src/main/res/drawable/vector_drawable_dark.xml new file mode 100644 index 0000000000..732c133583 --- /dev/null +++ b/instrumentation/src/main/res/drawable/vector_drawable_dark.xml @@ -0,0 +1,18 @@ + + + + diff --git a/instrumentation/src/main/res/drawable/vector_drawable_light.xml b/instrumentation/src/main/res/drawable/vector_drawable_light.xml new file mode 100644 index 0000000000..1732bb9ba5 --- /dev/null +++ b/instrumentation/src/main/res/drawable/vector_drawable_light.xml @@ -0,0 +1,18 @@ + + + + diff --git a/instrumentation/src/main/res/layout/default_fragment_activity.xml b/instrumentation/src/main/res/layout/default_fragment_activity.xml new file mode 100644 index 0000000000..54b71a1b23 --- /dev/null +++ b/instrumentation/src/main/res/layout/default_fragment_activity.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/instrumentation/src/main/res/raw/dl_world_anim_avif.avif b/instrumentation/src/main/res/raw/dl_world_anim_avif.avif new file mode 100644 index 0000000000..22c428a383 Binary files /dev/null and b/instrumentation/src/main/res/raw/dl_world_anim_avif.avif differ diff --git a/instrumentation/src/main/res/raw/dl_world_anim_webp.webp b/instrumentation/src/main/res/raw/dl_world_anim_webp.webp new file mode 100644 index 0000000000..a9152404dd Binary files /dev/null and b/instrumentation/src/main/res/raw/dl_world_anim_webp.webp differ diff --git a/instrumentation/src/main/res/raw/dog_dark.jpg b/instrumentation/src/main/res/raw/dog_dark.jpg new file mode 100644 index 0000000000..c91c8b2d17 Binary files /dev/null and b/instrumentation/src/main/res/raw/dog_dark.jpg differ diff --git a/instrumentation/src/main/res/raw/dog_light.jpg b/instrumentation/src/main/res/raw/dog_light.jpg new file mode 100644 index 0000000000..889ba27624 Binary files /dev/null and b/instrumentation/src/main/res/raw/dog_light.jpg differ diff --git a/instrumentation/src/main/res/values-night/colors.xml b/instrumentation/src/main/res/values-night/colors.xml new file mode 100644 index 0000000000..a14c94bdf4 --- /dev/null +++ b/instrumentation/src/main/res/values-night/colors.xml @@ -0,0 +1,4 @@ + + + #048b9f + \ No newline at end of file diff --git a/instrumentation/src/main/res/values/colors.xml b/instrumentation/src/main/res/values/colors.xml new file mode 100644 index 0000000000..615a7a7fa7 --- /dev/null +++ b/instrumentation/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + + #f9b840 + \ No newline at end of file diff --git a/instrumentation/src/main/res/values/styles.xml b/instrumentation/src/main/res/values/styles.xml new file mode 100644 index 0000000000..783a472b04 --- /dev/null +++ b/instrumentation/src/main/res/values/styles.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/integration/avif/build.gradle.kts b/integration/avif/build.gradle.kts new file mode 100644 index 0000000000..624c34ad8b --- /dev/null +++ b/integration/avif/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "com.bumptech.glide.integration.avif" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation(project(":library")) + implementation(libs.avif) + implementation(libs.guava) + + annotationProcessor(project(":annotation:compiler")) +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") \ No newline at end of file diff --git a/integration/avif/gradle.properties b/integration/avif/gradle.properties new file mode 100644 index 0000000000..388ebe1d62 --- /dev/null +++ b/integration/avif/gradle.properties @@ -0,0 +1,4 @@ +POM_NAME=Glide AVIF Integration +POM_ARTIFACT_ID=avif-integration +POM_PACKAGING=aar +POM_DESCRIPTION=An integration library to support AVIF images in Glide diff --git a/integration/avif/lint.xml b/integration/avif/lint.xml new file mode 100644 index 0000000000..ff7e5955c4 --- /dev/null +++ b/integration/avif/lint.xml @@ -0,0 +1,4 @@ + + + + diff --git a/integration/avif/src/main/java/com/bumptech/glide/integration/avif/AvifByteBufferBitmapDecoder.java b/integration/avif/src/main/java/com/bumptech/glide/integration/avif/AvifByteBufferBitmapDecoder.java new file mode 100644 index 0000000000..014fbeab94 --- /dev/null +++ b/integration/avif/src/main/java/com/bumptech/glide/integration/avif/AvifByteBufferBitmapDecoder.java @@ -0,0 +1,73 @@ +package com.bumptech.glide.integration.avif; + +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.util.Log; +import com.bumptech.glide.load.DecodeFormat; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; +import com.bumptech.glide.load.resource.bitmap.BitmapResource; +import com.bumptech.glide.load.resource.bitmap.Downsampler; +import com.bumptech.glide.util.Preconditions; +import java.nio.ByteBuffer; +import javax.annotation.Nullable; +import org.aomedia.avif.android.AvifDecoder; +import org.aomedia.avif.android.AvifDecoder.Info; + +/** A Glide {@link ResourceDecoder} capable of decoding Avif images. */ +public final class AvifByteBufferBitmapDecoder implements ResourceDecoder { + private static final String TAG = "AvifBitmapDecoder"; + + private final BitmapPool bitmapPool; + + public AvifByteBufferBitmapDecoder(BitmapPool bitmapPool) { + this.bitmapPool = Preconditions.checkNotNull(bitmapPool); + } + + private ByteBuffer maybeCopyBuffer(ByteBuffer source) { + // Native calls can only access ByteBuffer if isDirect() is true. Otherwise, we would have to + // make a copy into a direct ByteBuffer. + if (source.isDirect()) { + return source; + } + ByteBuffer sourceCopy = ByteBuffer.allocateDirect(source.remaining()); + sourceCopy.put(source); + sourceCopy.flip(); + return sourceCopy; + } + + @Override + @Nullable + public Resource decode(ByteBuffer source, int width, int height, Options options) { + ByteBuffer sourceCopy = maybeCopyBuffer(source); + Info info = new Info(); + if (!AvifDecoder.getInfo(sourceCopy, sourceCopy.remaining(), info)) { + if (Log.isLoggable(TAG, Log.ERROR)) { + Log.e(TAG, "Requested to decode byte buffer which cannot be handled by AvifDecoder"); + } + return null; + } + Bitmap.Config config; + if (options.get(Downsampler.DECODE_FORMAT) == DecodeFormat.PREFER_RGB_565) { + config = Config.RGB_565; + } else { + config = (info.depth == 8) ? Config.ARGB_8888 : Config.RGBA_F16; + } + Bitmap bitmap = bitmapPool.get(info.width, info.height, config); + if (!AvifDecoder.decode(sourceCopy, sourceCopy.remaining(), bitmap)) { + if (Log.isLoggable(TAG, Log.ERROR)) { + Log.e(TAG, "Failed to decode ByteBuffer as Avif."); + } + bitmapPool.put(bitmap); + return null; + } + return BitmapResource.obtain(bitmap, bitmapPool); + } + + @Override + public boolean handles(ByteBuffer source, Options options) { + return AvifDecoder.isAvifImage(maybeCopyBuffer(source)); + } +} diff --git a/integration/avif/src/main/java/com/bumptech/glide/integration/avif/AvifGlideModule.java b/integration/avif/src/main/java/com/bumptech/glide/integration/avif/AvifGlideModule.java new file mode 100644 index 0000000000..13dfdd2124 --- /dev/null +++ b/integration/avif/src/main/java/com/bumptech/glide/integration/avif/AvifGlideModule.java @@ -0,0 +1,43 @@ +package com.bumptech.glide.integration.avif; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import androidx.annotation.NonNull; +import com.bumptech.glide.Glide; +import com.bumptech.glide.Registry; +import com.bumptech.glide.annotation.GlideModule; +import com.bumptech.glide.load.resource.bitmap.BitmapDrawableDecoder; +import com.bumptech.glide.module.LibraryGlideModule; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/** Glide support for AVIF Images. */ +@GlideModule +public final class AvifGlideModule extends LibraryGlideModule { + + @Override + public void registerComponents( + @NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { + // Add the Avif ResourceDecoders before any of the available system decoders. This ensures that + // the integration will be preferred for Avif images. + AvifByteBufferBitmapDecoder byteBufferBitmapDecoder = + new AvifByteBufferBitmapDecoder(glide.getBitmapPool()); + registry.prepend( + Registry.BUCKET_BITMAP, ByteBuffer.class, Bitmap.class, byteBufferBitmapDecoder); + registry.prepend( + Registry.BUCKET_BITMAP_DRAWABLE, + ByteBuffer.class, + BitmapDrawable.class, + new BitmapDrawableDecoder<>(context.getResources(), byteBufferBitmapDecoder)); + AvifStreamBitmapDecoder streamBitmapDecoder = + new AvifStreamBitmapDecoder( + registry.getImageHeaderParsers(), byteBufferBitmapDecoder, glide.getArrayPool()); + registry.prepend(Registry.BUCKET_BITMAP, InputStream.class, Bitmap.class, streamBitmapDecoder); + registry.prepend( + Registry.BUCKET_BITMAP_DRAWABLE, + InputStream.class, + BitmapDrawable.class, + new BitmapDrawableDecoder<>(context.getResources(), streamBitmapDecoder)); + } +} diff --git a/integration/avif/src/main/java/com/bumptech/glide/integration/avif/AvifStreamBitmapDecoder.java b/integration/avif/src/main/java/com/bumptech/glide/integration/avif/AvifStreamBitmapDecoder.java new file mode 100644 index 0000000000..2e1ec10a4d --- /dev/null +++ b/integration/avif/src/main/java/com/bumptech/glide/integration/avif/AvifStreamBitmapDecoder.java @@ -0,0 +1,47 @@ +package com.bumptech.glide.integration.avif; + +import android.graphics.Bitmap; +import com.bumptech.glide.load.ImageHeaderParser; +import com.bumptech.glide.load.ImageHeaderParser.ImageType; +import com.bumptech.glide.load.ImageHeaderParserUtils; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; +import com.bumptech.glide.util.ByteBufferUtil; +import com.bumptech.glide.util.Preconditions; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import javax.annotation.Nullable; + +/** A Glide {@link ResourceDecoder} capable of decoding Avif Images. */ +public final class AvifStreamBitmapDecoder implements ResourceDecoder { + private static final String TAG = "AvifStreamBitmapDecoder"; + + private final List parsers; + private final AvifByteBufferBitmapDecoder avifByteBufferDecoder; + private final ArrayPool arrayPool; + + public AvifStreamBitmapDecoder( + List parsers, + AvifByteBufferBitmapDecoder avifByteBufferDecoder, + ArrayPool arrayPool) { + this.parsers = parsers; + this.avifByteBufferDecoder = Preconditions.checkNotNull(avifByteBufferDecoder); + this.arrayPool = Preconditions.checkNotNull(arrayPool); + } + + @Override + @Nullable + public Resource decode(InputStream source, int width, int height, Options options) + throws IOException { + return avifByteBufferDecoder.decode(ByteBufferUtil.fromStream(source), width, height, options); + } + + @Override + public boolean handles(InputStream source, Options options) throws IOException { + ImageType type = ImageHeaderParserUtils.getType(parsers, source, arrayPool); + return type.equals(ImageType.AVIF) || type.equals(ImageType.ANIMATED_AVIF); + } +} diff --git a/integration/build.gradle b/integration/build.gradle.kts similarity index 100% rename from integration/build.gradle rename to integration/build.gradle.kts diff --git a/integration/compose/api/compose.api b/integration/compose/api/compose.api new file mode 100644 index 0000000000..5185e1f008 --- /dev/null +++ b/integration/compose/api/compose.api @@ -0,0 +1,24 @@ +public abstract interface annotation class com/bumptech/glide/integration/compose/ExperimentalGlideComposeApi : java/lang/annotation/Annotation { +} + +public final class com/bumptech/glide/integration/compose/GlideImageKt { + public static final fun GlideImage (Ljava/lang/Object;Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/layout/ContentScale;FLandroidx/compose/ui/graphics/ColorFilter;Lcom/bumptech/glide/integration/compose/Placeholder;Lcom/bumptech/glide/integration/compose/Placeholder;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun placeholder (I)Lcom/bumptech/glide/integration/compose/Placeholder; + public static final fun placeholder (Landroid/graphics/drawable/Drawable;)Lcom/bumptech/glide/integration/compose/Placeholder; + public static final fun placeholder (Lkotlin/jvm/functions/Function2;)Lcom/bumptech/glide/integration/compose/Placeholder; +} + +public abstract interface class com/bumptech/glide/integration/compose/GlidePreloadingData { + public abstract fun get (ILandroidx/compose/runtime/Composer;I)Lkotlin/Pair; + public abstract fun getSize ()I +} + +public abstract class com/bumptech/glide/integration/compose/Placeholder { + public static final field $stable I +} + +public final class com/bumptech/glide/integration/compose/PreloadKt { + public static final fun rememberGlidePreloadingData-Z8o_i8w (Ljava/util/List;JILjava/lang/Integer;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Lcom/bumptech/glide/integration/compose/GlidePreloadingData; + public static final fun rememberGlidePreloadingData-u6VnWhU (ILkotlin/jvm/functions/Function1;JILjava/lang/Integer;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Lcom/bumptech/glide/integration/compose/GlidePreloadingData; +} + diff --git a/integration/compose/build.gradle.kts b/integration/compose/build.gradle.kts new file mode 100644 index 0000000000..b60acf1394 --- /dev/null +++ b/integration/compose/build.gradle.kts @@ -0,0 +1,67 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { id("com.android.library") } + +apply(plugin = "org.jetbrains.kotlin.plugin.compose") + +android { + namespace = "com.bumptech.glide.integration.compose" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = 23 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { compose = true } + + buildTypes { getByName("release") { isMinifyEnabled = false } } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + testOptions { unitTests { isIncludeAndroidResources = true } } +} + +kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_1_8) } } + +tasks.withType().configureEach { + if (!name.contains("Test")) { + compilerOptions.freeCompilerArgs.add("-Xexplicit-api=strict") + } +} + +dependencies { + implementation(project(":library")) + implementation(project(":integration:ktx")) + + implementation(project(":integration:recyclerview")) { isTransitive = false } + + implementation(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.drawablepainter) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + debugImplementation(libs.compose.ui.testmanifest) + testImplementation(libs.compose.ui.testmanifest) + testImplementation(libs.compose.ui.testjunit4) + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.appcompat) + testImplementation(libs.androidx.junit) + testImplementation(libs.androidx.test.runner) + testImplementation(libs.androidx.lifecycle.runtime.testing) + androidTestImplementation(libs.junit) + androidTestImplementation(libs.compose.ui.testjunit4) + androidTestImplementation(libs.androidx.espresso) + androidTestImplementation(libs.androidx.espresso.idling) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.compose.material) + androidTestImplementation(libs.truth) + androidTestImplementation(project(":testutil")) +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") diff --git a/integration/compose/gradle.properties b/integration/compose/gradle.properties new file mode 100644 index 0000000000..3a46564bef --- /dev/null +++ b/integration/compose/gradle.properties @@ -0,0 +1,9 @@ +POM_NAME=Glide Compose Integration +POM_ARTIFACT_ID=compose +POM_PACKAGING=aar +POM_DESCRIPTION=An integration library to integrate with Jetpack Compose + +VERSION_MAJOR=1 +VERSION_MINOR=0 +VERSION_PATCH=0 +VERSION_NAME=1.0.0-beta08 \ No newline at end of file diff --git a/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageCustomDrawableTransformationTest.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageCustomDrawableTransformationTest.kt new file mode 100644 index 0000000000..6eee8b8828 --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageCustomDrawableTransformationTest.kt @@ -0,0 +1,143 @@ +@file:OptIn(ExperimentalGlideComposeApi::class, ExperimentalCoroutinesApi::class) + +package com.bumptech.glide.integration.compose + +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.drawable.Animatable +import android.graphics.drawable.Drawable +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.ScaleFactor +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.testing.TestLifecycleOwner +import com.bumptech.glide.integration.compose.test.Constants +import com.bumptech.glide.integration.compose.test.GlideComposeRule +import com.bumptech.glide.integration.compose.test.assertDisplaysInstance +import com.bumptech.glide.integration.compose.test.onNodeWithDefaultContentDescription +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +/** + * Tests Issue #4943. + * + * Transformable types are tested in [GlideImageDefaultTransformationTest]. + */ +@RunWith(Parameterized::class) +class GlideImageCustomDrawableTransformationTest( + private val contentScale: ContentScale, + // We need a shorter test name than the ContentScale class name to make google3 happy, so we + // add an extra parameter. Unfortunately that means we need to list it in the constructor even + // though it's only used by Parameters to create the test name. + @Suppress("unused") private val name: String, +) { + @get:Rule val glideComposeRule = GlideComposeRule() + + @Test + fun glideImage_nonBitmapDrawable_doesNotThrow() = runTest { + val customDrawable = FakeDrawable() + + glideComposeRule.setContent { GlideImageWithCustomDrawable(customDrawable) } + + glideComposeRule + .onNodeWithDefaultContentDescription() + .assertDisplaysInstance(customDrawable) + } + + @Test + fun glideImage_animatableDrawable_doesNotThrow() = runTest { + val customDrawable = FakeAnimatableDrawable() + + glideComposeRule.setContent { GlideImageWithCustomDrawable(customDrawable) } + + glideComposeRule + .onNodeWithDefaultContentDescription() + .assertDisplaysInstance(customDrawable) + } + + @Test + fun glideImage_animatableDrawable_stopsAnimationWhenLifecycleNotStarted() = runTest { + val customDrawable = FakeAnimatableDrawable() + val testLifecycleOwner = TestLifecycleOwner(initialState = Lifecycle.State.STARTED) + + glideComposeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides testLifecycleOwner) { + GlideImageWithCustomDrawable(customDrawable) + } + } + assertThat(customDrawable.animating).isTrue() + testLifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + assertThat(customDrawable.animating).isFalse() + } + + @Composable + private fun GlideImageWithCustomDrawable(customDrawable: FakeDrawable) { + GlideImage( + model = customDrawable, + contentScale = contentScale, + contentDescription = Constants.DEFAULT_DESCRIPTION, + modifier = Modifier.size(200.dp, 100.dp), + ) + } + + companion object { + // Add a second parameter purely to make the test name shorter, see the comment on the test + // constructor argument for details. + @JvmStatic + @Parameterized.Parameters(name = "{1}") + fun data() = + arrayOf( + arrayOf(ContentScale.Crop, "Crop"), + arrayOf(ContentScale.FillBounds, "FillBounds"), + arrayOf(ContentScale.FillHeight, "FillHeight"), + arrayOf(ContentScale.FillWidth, "FillWidth"), + arrayOf(ContentScale.Fit, "Fit"), + arrayOf(ContentScale.Inside, "Inside"), + arrayOf(ContentScale.None, "None"), + arrayOf( + object : ContentScale { + override fun computeScaleFactor(srcSize: Size, dstSize: Size): ScaleFactor = + ContentScale.Fit.computeScaleFactor(srcSize, dstSize) + }, + "Custom", + ), + ) + } +} + +@Suppress("DeprecatedCallableAddReplaceWith") +private open class FakeDrawable : Drawable() { + override fun draw(p0: Canvas) {} + + override fun setAlpha(p0: Int) = throw UnsupportedOperationException() + + override fun setColorFilter(p0: ColorFilter?) = throw UnsupportedOperationException() + + @Deprecated("Deprecated in Java") + override fun getOpacity(): Int = throw UnsupportedOperationException() +} + +private class FakeAnimatableDrawable : FakeDrawable(), Animatable { + var animating: Boolean? = null + + override fun start() { + animating = true + } + + override fun stop() { + animating = false + } + + override fun isRunning(): Boolean = throw UnsupportedOperationException() +} diff --git a/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageDefaultTransformationTest.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageDefaultTransformationTest.kt new file mode 100644 index 0000000000..8608d58b04 --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageDefaultTransformationTest.kt @@ -0,0 +1,171 @@ +@file:OptIn( + ExperimentalCoroutinesApi::class, + ExperimentGlideFlows::class, + ExperimentalGlideComposeApi::class, +) + +package com.bumptech.glide.integration.compose + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.integration.compose.test.Constants +import com.bumptech.glide.integration.compose.test.GlideComposeRule +import com.bumptech.glide.integration.compose.test.assertDisplays +import com.bumptech.glide.integration.compose.test.dpToPixels +import com.bumptech.glide.integration.compose.test.onNodeWithDefaultContentDescription +import com.bumptech.glide.integration.ktx.ExperimentGlideFlows +import com.bumptech.glide.integration.ktx.Resource +import com.bumptech.glide.integration.ktx.Status +import com.bumptech.glide.integration.ktx.flow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +/** Non-transformable types are tested in [GlideImageCustomDrawableTransformationTest] */ +class GlideImageDefaultTransformationTest { + private val context: Context = ApplicationProvider.getApplicationContext() + @get:Rule val glideComposeRule = GlideComposeRule() + + @Test + fun glideImage_withContentScaleNone_noTransformation_doesNotApplyTransformation() = runTest { + val resourceId = android.R.drawable.star_big_on + val expectedDrawable = loadExpectedDrawable(resourceId) + + glideComposeRule.setContent { + ContentScaleGlideImage(model = resourceId, contentScale = ContentScale.None) + } + + glideComposeRule.onNodeWithDefaultContentDescription().assertDisplays(expectedDrawable) + } + + @Test + fun glideImage_withContentScaleFit_noTransformation_appliesCenterInsideTransformation() = + runTest { + val resourceId = android.R.drawable.star_big_on + val expectedDrawable = loadExpectedDrawable(resourceId) { it.centerInside() } + + glideComposeRule.setContent { + ContentScaleGlideImage(model = resourceId, contentScale = ContentScale.Fit) + } + + glideComposeRule.onNodeWithDefaultContentDescription().assertDisplays(expectedDrawable) + } + + @Test + fun glideImage_withContentScaleFit_explicitTransformation_usesExplicitTransformation() = + runTest { + val resourceId = android.R.drawable.star_big_on + val expectedDrawable = loadExpectedDrawable(resourceId) { it.centerCrop() } + + glideComposeRule.setContent { + ContentScaleGlideImage(model = resourceId, contentScale = ContentScale.Fit) { + it.centerCrop() + } + } + + glideComposeRule.onNodeWithDefaultContentDescription().assertDisplays(expectedDrawable) + } + + @Test + fun glideImage_withContentScaleInside_noTransformation_appliesCenterInsideTransformation() = + runTest { + val resourceId = android.R.drawable.star_big_on + val expectedDrawable = loadExpectedDrawable(resourceId) { it.centerInside() } + + glideComposeRule.setContent { + ContentScaleGlideImage(model = resourceId, contentScale = ContentScale.Inside) + } + + glideComposeRule.onNodeWithDefaultContentDescription().assertDisplays(expectedDrawable) + } + + @Test + fun glideImage_withContentScaleInside_explicitTransformation_usesExplicitTransformation() = + runTest { + val resourceId = android.R.drawable.star_big_on + val expectedDrawable = loadExpectedDrawable(resourceId) { it.centerCrop() } + + glideComposeRule.setContent { + ContentScaleGlideImage(model = resourceId, contentScale = ContentScale.Inside) { + it.centerCrop() + } + } + + glideComposeRule.onNodeWithDefaultContentDescription().assertDisplays(expectedDrawable) + } + + @Test + fun glideImage_withContentScaleCrop_noTransformation_appliesCenterCropTransformation() = + runTest { + val resourceId = android.R.drawable.star_big_on + val expectedDrawable = loadExpectedDrawable(resourceId) { it.centerCrop() } + + glideComposeRule.setContent { + ContentScaleGlideImage(model = resourceId, contentScale = ContentScale.Crop) + } + + glideComposeRule.onNodeWithDefaultContentDescription().assertDisplays(expectedDrawable) + } + + @Test + fun glideImage_withContentScaleCrop_explicitTransformation_usesExplicitTransformation() = + runTest { + val resourceId = android.R.drawable.star_big_on + val expectedDrawable = loadExpectedDrawable(resourceId) { it.centerInside() } + + glideComposeRule.setContent { + ContentScaleGlideImage(model = resourceId, contentScale = ContentScale.Crop) { + it.centerInside() + } + } + + glideComposeRule.onNodeWithDefaultContentDescription().assertDisplays(expectedDrawable) + } + + private suspend fun RequestBuilder.loadRequiringSuccess() = + (this.flow().first { it.status == Status.SUCCEEDED } as Resource).resource + + private suspend fun loadExpectedDrawable( + @DrawableRes resourceId: Int, + transformation: (RequestBuilder) -> RequestBuilder = { it -> it }, + ): Drawable = + transformation( + Glide.with(context) + .load(resourceId) + .override(WIDTH.dpToPixels(), HEIGHT.dpToPixels()) + ) + .loadRequiringSuccess() + + @Composable + private fun ContentScaleGlideImage( + model: Any?, + contentScale: ContentScale, + requestBuilderTransform: RequestBuilderTransform = { it -> it }, + ) = + GlideImage( + model = model, + contentDescription = Constants.DEFAULT_DESCRIPTION, + modifier = SIZE_MODIFIER, + contentScale = contentScale, + requestBuilderTransform = requestBuilderTransform, + ) + + companion object { + const val WIDTH = 25 + // non-square + const val HEIGHT = 30 + + val SIZE_MODIFIER = Modifier.size(WIDTH.dp, HEIGHT.dp) + } +} diff --git a/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageErrorTest.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageErrorTest.kt new file mode 100644 index 0000000000..d0d31db198 --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageErrorTest.kt @@ -0,0 +1,206 @@ +@file:OptIn(ExperimentalGlideComposeApi::class) + +package com.bumptech.glide.integration.compose + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.test.core.app.ApplicationProvider +import com.bumptech.glide.integration.compose.test.GlideComposeRule +import com.bumptech.glide.integration.compose.test.expectDisplayedDrawable +import com.bumptech.glide.integration.compose.test.expectDisplayedResource +import com.bumptech.glide.integration.compose.test.expectNoDrawable +import org.junit.Rule +import org.junit.Test + +/** + * Avoids [com.bumptech.glide.load.engine.executor.GlideIdlingResourceInit] because we want to make + * assertions about loads that have not yet completed. + */ +class GlideImageErrorTest { + private val context: Context = ApplicationProvider.getApplicationContext() + @get:Rule val glideComposeRule = GlideComposeRule() + + @Test + fun requestBuilderTransform_withErrorResourceId_displaysError() { + val description = "test" + val errorResourceId = android.R.drawable.star_big_off + glideComposeRule.setContent { + GlideImage(model = null, contentDescription = description) { it.error(errorResourceId) } + } + + glideComposeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedResource(errorResourceId)) + } + + @Test + fun requestBuilderTransform_withErrorDrawable_displaysError() { + val description = "test" + val errorDrawable = context.getDrawable(android.R.drawable.star_big_off) + glideComposeRule.setContent { + GlideImage(model = null, contentDescription = description) { it.error(errorDrawable) } + } + + glideComposeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(errorDrawable)) + } + + @Test + fun failureParameter_withErrorResourceId_displaysError() { + val description = "test" + val failureResourceId = android.R.drawable.star_big_off + glideComposeRule.setContent { + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(failureResourceId), + ) + } + + glideComposeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedResource(failureResourceId)) + } + + @Test + fun failureParameter_withDrawable_displaysDrawable() { + val description = "test" + val failureDrawable = context.getDrawable(android.R.drawable.star_big_off) + glideComposeRule.setContent { + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(failureDrawable), + ) + } + + glideComposeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(failureDrawable)) + } + + @Test + fun failureParameter_withNullDrawable_displaysNothing() { + val description = "test" + glideComposeRule.setContent { + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(null as Drawable?), + ) + } + + glideComposeRule.onNodeWithContentDescription(description).assert(expectNoDrawable()) + } + + @Test + fun failureParameter_withComposable_displaysComposable() { + val failureResourceId = android.R.drawable.star_big_off + val description = "test" + glideComposeRule.setContent { + GlideImage( + model = null, + contentDescription = "none", + failure = + placeholder { + // Nesting GlideImage is not really a good idea, but it's convenient for + // this test + // because + // we can use our helpers to assert on its contents. + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(failureResourceId), + ) + }, + ) + } + + glideComposeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedResource(failureResourceId)) + } + + @Test + fun failure_setViaFailureParameterWithResourceId_andRequestBuilderTransform_prefersFailureParameter() { + val description = "test" + val failureResourceId = android.R.drawable.star_big_off + glideComposeRule.setContent { + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(failureResourceId), + ) { + it.error(android.R.drawable.btn_star) + } + } + + glideComposeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedResource(failureResourceId)) + } + + @Test + fun failure_setViaFailureParameterWithDrawable_andRequestBuilderTransform_prefersFailureParameter() { + val description = "test" + val failureDrawable = context.getDrawable(android.R.drawable.star_big_off) + glideComposeRule.setContent { + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(failureDrawable), + ) { + it.error(android.R.drawable.btn_star) + } + } + + glideComposeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(failureDrawable)) + } + + @Test + fun failure_setViaFailureParameterWithNullDrawable_andRequestBuilderTransformWithNonNullDrawable_showsNoPlaceholder() { + val description = "test" + glideComposeRule.setContent { + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(null as Drawable?), + ) { + it.error(android.R.drawable.btn_star) + } + } + + glideComposeRule.onNodeWithContentDescription(description).assert(expectNoDrawable()) + } + + @Test + fun failure_setViaFailureParameterWithComposable_andRequestBuilderTransform_showsComposable() { + val description = "test" + val failureResourceId = android.R.drawable.star_big_off + glideComposeRule.setContent { + GlideImage( + model = null, + contentDescription = "other", + failure = + placeholder { + GlideImage( + model = null, + contentDescription = description, + failure = placeholder(failureResourceId), + ) + }, + ) { + it.error(android.R.drawable.btn_star) + } + } + + glideComposeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedResource(failureResourceId)) + } +} diff --git a/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImagePlaceholderTest.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImagePlaceholderTest.kt new file mode 100644 index 0000000000..862330ea59 --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImagePlaceholderTest.kt @@ -0,0 +1,225 @@ +@file:OptIn(ExperimentalGlideComposeApi::class) + +package com.bumptech.glide.integration.compose + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.test.core.app.ApplicationProvider +import com.bumptech.glide.integration.compose.test.expectDisplayedDrawable +import com.bumptech.glide.integration.compose.test.expectDisplayedResource +import com.bumptech.glide.integration.compose.test.expectNoDrawable +import com.bumptech.glide.testutil.TearDownGlide +import com.bumptech.glide.testutil.WaitModelLoaderRule +import org.junit.Rule +import org.junit.Test + +/** + * Avoids [com.bumptech.glide.load.engine.executor.GlideIdlingResourceInit] and + * [com.bumptech.glide.integration.compose.test.GlideComposeRule] because we want to make assertions + * about loads that have not yet completed. + */ +class GlideImagePlaceholderTest { + private val context: Context = ApplicationProvider.getApplicationContext() + @get:Rule(order = 1) val composeRule = createComposeRule() + @get:Rule(order = 2) val waitModelLoaderRule = WaitModelLoaderRule() + @get:Rule(order = 3) val tearDownGlide = TearDownGlide() + + @Test + fun requestBuilderTransform_withPlaceholderResourceId_displaysPlaceholder() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderResourceId = android.R.drawable.star_big_off + composeRule.setContent { + GlideImage(model = waitModel, contentDescription = description) { + it.placeholder(placeholderResourceId) + } + } + + composeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedResource(placeholderResourceId)) + } + + @Test + fun requestBuilderTransform_withPlaceholderDrawable_displaysPlaceholder() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderDrawable = context.getDrawable(android.R.drawable.star_big_off) + composeRule.setContent { + GlideImage(model = waitModel, contentDescription = description) { + it.placeholder(placeholderDrawable) + } + } + + composeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(placeholderDrawable)) + } + + @Test + fun loadingParameter_withResourceId_displaysResource() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderResourceId = android.R.drawable.star_big_off + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(placeholderResourceId), + ) + } + + composeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedResource(placeholderResourceId)) + } + + @Test + fun loadingParameter_withDrawable_displaysResource() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderDrawable = context.getDrawable(android.R.drawable.star_big_off) + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(placeholderDrawable), + ) + } + + composeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(placeholderDrawable)) + } + + @Test + fun loadingParameter_withNullDrawable_displaysNothing() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(null as Drawable?), + ) + } + + composeRule.onNodeWithContentDescription(description).assert(expectNoDrawable()) + } + + @Test + fun loadingParameter_withComposable_displaysComposable() { + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderResourceId = android.R.drawable.star_big_off + val description = "test" + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = "none", + loading = + placeholder { + // Nesting GlideImage is not really a good idea, but it's convenient for + // this test + // because + // we can use our helpers to assert on its contents. + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(placeholderResourceId), + ) + }, + ) + } + + composeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedResource(placeholderResourceId)) + } + + @Test + fun loading_setViaLoadingParameterWithResourceId_andRequestBuilderTransform_prefersLoadingParameter() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderResourceId = android.R.drawable.star_big_off + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(placeholderResourceId), + ) { + it.placeholder(android.R.drawable.btn_star) + } + } + + composeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedResource(placeholderResourceId)) + } + + @Test + fun loading_setViaLoadingParameterWithDrawable_andRequestBuilderTransform_prefersLoadingParameter() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderDrawable = context.getDrawable(android.R.drawable.star_big_off) + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(placeholderDrawable), + ) { + it.placeholder(android.R.drawable.btn_star) + } + } + + composeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(placeholderDrawable)) + } + + @Test + fun loading_setViaLoadingParameterWithNullDrawable_andRequestBuilderTransform_showsNoResource() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(null as Drawable?), + ) { + it.placeholder(android.R.drawable.btn_star) + } + } + + composeRule.onNodeWithContentDescription(description).assert(expectNoDrawable()) + } + + @Test + fun loading_setViaLoadingParameterWithComposable_andRequestBuilderTransform_showsComposable() { + val description = "test" + val waitModel = waitModelLoaderRule.waitOn(android.R.drawable.star_big_on) + val placeholderResourceId = android.R.drawable.star_big_off + composeRule.setContent { + GlideImage( + model = waitModel, + contentDescription = "other", + loading = + placeholder { + GlideImage( + model = waitModel, + contentDescription = description, + loading = placeholder(placeholderResourceId), + ) + }, + ) { + it.placeholder(android.R.drawable.btn_star) + } + } + + composeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedResource(placeholderResourceId)) + } +} diff --git a/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageTest.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageTest.kt new file mode 100644 index 0000000000..bd2e2b1af6 --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/GlideImageTest.kt @@ -0,0 +1,310 @@ +@file:OptIn(ExperimentalGlideComposeApi::class, InternalGlideApi::class) + +package com.bumptech.glide.integration.compose + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import com.bumptech.glide.Glide +import com.bumptech.glide.integration.compose.test.GlideComposeRule +import com.bumptech.glide.integration.compose.test.assertDisplays +import com.bumptech.glide.integration.compose.test.bitmapSize +import com.bumptech.glide.integration.compose.test.dpToPixels +import com.bumptech.glide.integration.compose.test.expectDisplayedDrawable +import com.bumptech.glide.integration.compose.test.expectDisplayedDrawableSize +import com.bumptech.glide.integration.ktx.InternalGlideApi +import com.bumptech.glide.integration.ktx.Size +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import org.junit.Rule +import org.junit.Test + +class GlideImageTest { + private val context: Context = ApplicationProvider.getApplicationContext() + @get:Rule val glideComposeRule = GlideComposeRule() + + @Test + fun glideImage_noModifierSize_resourceDrawable_displaysDrawable() { + val description = "test" + val resourceId = android.R.drawable.star_big_on + glideComposeRule.setContent { + GlideImage(model = resourceId, contentDescription = description) + } + + glideComposeRule.waitForIdle() + + val expectedSize = resourceId.bitmapSize() + glideComposeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawableSize(expectedSize)) + } + + @Test + fun glideImage_withSizeLargerThanImage_noTransformSet_doesNotUpscaleImage() { + val description = "test" + val resourceId = android.R.drawable.star_big_on + glideComposeRule.setContent { + GlideImage( + model = resourceId, + contentDescription = description, + modifier = Modifier.size(300.dp, 300.dp), + ) + } + + glideComposeRule.waitForIdle() + + val expectedSize = resourceId.bitmapSize() + glideComposeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawableSize(expectedSize)) + } + + @Test + fun glideImage_withChangingModel_refreshes() { + val description = "test" + + val firstDrawable: Drawable = context.getDrawable(android.R.drawable.star_big_off)!! + val secondDrawable: Drawable = context.getDrawable(android.R.drawable.star_big_on)!! + + glideComposeRule.setContent { + val model = remember { mutableStateOf(firstDrawable) } + + fun swapModel() { + model.value = secondDrawable + } + + Column { + TextButton(onClick = ::swapModel) { Text(text = "Swap") } + GlideImage( + model = model.value, + modifier = Modifier.size(100.dp), + contentDescription = description, + ) + } + } + + glideComposeRule.waitForIdle() + glideComposeRule.onNodeWithText("Swap").performClick() + glideComposeRule.waitForIdle() + + glideComposeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(secondDrawable)) + } + + @Test + fun glideImage_withSizeLargerThanImage_upscaleTransformSet_upscalesImage() { + val viewDimension = 300 + val description = "test" + val sizeRef = AtomicReference() + glideComposeRule.setContent { + GlideImage( + model = android.R.drawable.star_big_on, + requestBuilderTransform = { it.fitCenter() }, + contentDescription = description, + modifier = Modifier.size(viewDimension.dp, viewDimension.dp), + ) + + with(LocalDensity.current) { + val pixels = viewDimension.dp.roundToPx() + sizeRef.set(Size(pixels, pixels)) + } + } + + glideComposeRule.waitForIdle() + + val pixels = sizeRef.get() + glideComposeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawableSize(pixels)) + } + + @Test + fun glideImage_withThumbnail_prefersFullSizeImage() { + val description = "test" + val thumbnailDrawable = context.getDrawable(android.R.drawable.star_big_off) + val fullsizeDrawable = context.getDrawable(android.R.drawable.star_big_on) + + glideComposeRule.setContent { + GlideImage( + model = fullsizeDrawable, + requestBuilderTransform = { + it.thumbnail(Glide.with(context).load(thumbnailDrawable)) + }, + contentDescription = description, + ) + } + + glideComposeRule.waitForIdle() + glideComposeRule + .onNodeWithContentDescription(description) + .assert(expectDisplayedDrawable(fullsizeDrawable)) + } + + @Test + fun glideImage_withZeroSize_doesNotStartLoad() { + val description = "test" + glideComposeRule.setContent { + Box(modifier = Modifier.size(0.dp)) { + GlideImage(model = android.R.drawable.star_big_on, contentDescription = description) + } + } + glideComposeRule.waitForIdle() + glideComposeRule.onNodeWithContentDescription(description).assertDisplays(null) + } + + @Test + fun glideImage_withNegativeSize_doesNotStartLoad() { + val description = "test" + glideComposeRule.setContent { + Box(modifier = Modifier.size((-10).dp)) { + GlideImage(model = android.R.drawable.star_big_on, contentDescription = description) + } + } + glideComposeRule.waitForIdle() + glideComposeRule.onNodeWithContentDescription(description).assertDisplays(null) + } + + @Test + fun glideImage_withZeroWidth_validHeight_doesNotStartLoad() { + val description = "test" + glideComposeRule.setContent { + Box(modifier = Modifier.size(0.dp, 10.dp)) { + GlideImage(model = android.R.drawable.star_big_on, contentDescription = description) + } + } + glideComposeRule.waitForIdle() + glideComposeRule.onNodeWithContentDescription(description).assertDisplays(null) + } + + @Test + fun glideImage_withValidWidth_zeroHeight_doesNotStartLoad() { + val description = "test" + glideComposeRule.setContent { + Box(modifier = Modifier.size(10.dp, 0.dp)) { + GlideImage(model = android.R.drawable.star_big_on, contentDescription = description) + } + } + glideComposeRule.waitForIdle() + glideComposeRule.onNodeWithContentDescription(description).assertDisplays(null) + } + + @Test + fun glideImage_withZeroSize_thenValidSize_startsLoadWithValidSize() { + val description = "test" + val resourceId = android.R.drawable.star_big_on + val validSizeDp = 10 + glideComposeRule.setContent { + val currentSize = remember { mutableStateOf(0.dp) } + fun swapSize() { + currentSize.value = validSizeDp.dp + } + + TextButton(onClick = ::swapSize) { Text(text = "Swap") } + Box(modifier = Modifier.size(currentSize.value)) { + GlideImage(model = resourceId, contentDescription = description) + } + } + glideComposeRule.waitForIdle() + glideComposeRule.onNodeWithText("Swap").performClick() + glideComposeRule.waitForIdle() + + glideComposeRule + .onNodeWithContentDescription(description) + .assert( + expectDisplayedDrawableSize( + Size(validSizeDp.dpToPixels(), validSizeDp.dpToPixels()) + ) + ) + } + + @Test + fun glideImage_withZeroSize_thenMultipleValidSizes_startsLoadWithFirstValidSize() { + val description = "test" + val resourceId = android.R.drawable.star_big_on + val validSizeDps = listOf(10, 20, 30, 40) + glideComposeRule.setContent { + val currentSize = remember { mutableStateOf(0.dp) } + val currentSizeIndex = remember { mutableStateOf(0) } + fun swapSize() { + currentSize.value = validSizeDps[currentSizeIndex.value].dp + currentSizeIndex.value++ + } + + TextButton(onClick = ::swapSize) { Text(text = "Swap") } + Box(modifier = Modifier.size(currentSize.value)) { + GlideImage(model = resourceId, contentDescription = description) + } + } + repeat(validSizeDps.size) { + glideComposeRule.waitForIdle() + glideComposeRule.onNodeWithText("Swap").performClick() + } + glideComposeRule.waitForIdle() + + val expectedSize = validSizeDps[0] + glideComposeRule + .onNodeWithContentDescription(description) + .assert( + expectDisplayedDrawableSize( + Size(expectedSize.dpToPixels(), expectedSize.dpToPixels()) + ) + ) + } + + @Test + fun glideImage_withSuccessfulResource_callsOnResourceReadyOnce() { + val onResourceReadyCounter = AtomicInteger() + val requestListener = + object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean, + ): Boolean { + throw UnsupportedOperationException() + } + + override fun onResourceReady( + resource: Drawable?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean, + ): Boolean { + onResourceReadyCounter.incrementAndGet() + return false + } + } + + glideComposeRule.setContent { + GlideImage(model = android.R.drawable.star_big_on, contentDescription = "") { + it.listener(requestListener) + } + } + + glideComposeRule.waitForIdle() + + assertThat(onResourceReadyCounter.get()).isEqualTo(1) + } +} diff --git a/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/RememberGlidePreloadingDataTest.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/RememberGlidePreloadingDataTest.kt new file mode 100644 index 0000000000..f34309b515 --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/RememberGlidePreloadingDataTest.kt @@ -0,0 +1,241 @@ +@file:OptIn(ExperimentalGlideComposeApi::class, ExperimentalGlideComposeApi::class) + +package com.bumptech.glide.integration.compose + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ApplicationProvider +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.integration.compose.test.GlideComposeRule +import com.bumptech.glide.request.target.Target +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test + +class RememberGlidePreloadingDataTest { + private val context: Context = ApplicationProvider.getApplicationContext() + @get:Rule val glideComposeRule = GlideComposeRule() + + @Test + fun rememberGlidePreloadingData_withoutScroll_preloadsNextItem() { + glideComposeRule.setContent { + val preloadingData = rememberOneItemAtATimePreloadingData() + + LazyRow(modifier = Modifier.testTag(listTestTag)) { + items(preloadingData.size) { index -> + preloadingData.triggerPreload(index) + GlideImage( + model = model, + contentDescription = imageContentDescription(index), + Modifier.fillParentMaxWidth(), + ) + } + } + } + + assertThatModelIsInMemoryCache(preloadModels[1]) + } + + @Test + fun glideLazyListPreloader_onScroll_preloadsAheadInDirectionOfScroll() { + glideComposeRule.setContent { + val preloadingData = rememberOneItemAtATimePreloadingData() + LazyRow(modifier = Modifier.testTag(listTestTag)) { + items(preloadingData.size) { index -> + preloadingData.triggerPreload(index) + GlideImage( + model = model, + contentDescription = imageContentDescription(index), + Modifier.fillParentMaxWidth(), + ) + } + } + } + + val scrollToIndex = 1 + glideComposeRule.onNode(hasTestTag(listTestTag)).performScrollToIndex(scrollToIndex) + + assertThatModelIsInMemoryCache(preloadModels[2]) + } + + @Test + fun glideLazyListPreloader_withHeaderItem_onScroll_doesNotCrash() { + glideComposeRule.setContent { + val preloadingData = rememberOneItemAtATimePreloadingData() + + LazyRow(modifier = Modifier.testTag(listTestTag)) { + item { Text(text = "Header") } + items(preloadingData.size) { index -> + preloadingData.triggerPreload(index) + GlideImage( + model = model, + contentDescription = imageContentDescription(index), + Modifier.fillParentMaxWidth(), + ) + } + } + } + + // Scroll to the 0th image, accounting for the first header item. + val scrollToIndex = 1 + glideComposeRule.onNode(hasTestTag(listTestTag)).performScrollToIndex(scrollToIndex) + // Make sure the next image, the 1th, is in memory due to preloading. + assertThatModelIsInMemoryCache(preloadModels[1]) + } + + @Test + fun glideLazyListPreloader_whenDataChanges_onScroll_preloadsUpdatedData() { + glideComposeRule.setContent { + // Swap both to avoid confusing the preloader. The preloader doesn't notice or take into + // account data set changes (this is a bug in the Java preloading API)... + val currentPreloadModels = remember { mutableStateListOf() } + val currentModels = remember { mutableStateListOf() } + // Use a button to swap data because we can't mutate state in setContent easily from + // outside + // the method, nor can you call setContent multiple times. + fun swapData() { + currentPreloadModels.addAll(preloadModels) + currentModels.addAll(preloadModels) + } + val preloadData = + rememberGlidePreloadingData( + data = currentPreloadModels, + preloadImageSize = Target.SIZE_ORIGINAL.toSize(), + numberOfItemsToPreload = 1, + fixedVisibleItemCount = 1, + ) { data: Int, requestBuilder: RequestBuilder -> + requestBuilder.load(data).removeTheme() + } + + TextButton(onClick = ::swapData) { Text(text = "Swap") } + + Column { + LazyRow( + modifier = Modifier.testTag(listTestTag), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(currentModels.size) { index -> + // This mismatch between currentModels and preloadData may lead to errors in + // the future + // because items may be recomposed before the setContent method's function + // is + // recomposed. See https://chat.google.com/room/AAAAYRnp4-Y/AvFrBgb_peU for + // a bunch of + // detailed discussion. + preloadData.triggerPreload(index) + GlideImage( + model = currentModels[index], + contentDescription = imageContentDescription(index), + Modifier.fillParentMaxWidth(), + ) + } + } + } + } + + glideComposeRule.onNodeWithText("Swap").performClick() + glideComposeRule.waitForIdle() + val scrollToIndex = 1 + glideComposeRule.onNode(hasTestTag(listTestTag)).performScrollToIndex(scrollToIndex) + + assertThatModelIsInMemoryCache(preloadModels[scrollToIndex + 1]) + } + + @Test + fun glideLazyListPreloader_withHeaderItems_andPositionFunction_onScroll_preloadsTheFirstItem() { + val numHeaderItems = 3 + glideComposeRule.setContent { + val data = rememberOneItemAtATimePreloadingData() + LazyRow(modifier = Modifier.testTag(listTestTag)) { + repeat(numHeaderItems) { item { Text(text = "Header$it") } } + items(data.size) { index -> + data.triggerPreload(index) + GlideImage( + model = model, + contentDescription = imageContentDescription(index), + Modifier.fillParentMaxWidth(), + ) + } + } + } + + val imageIndex = 1 + val scrollToIndex = numHeaderItems + imageIndex + glideComposeRule.onNode(hasTestTag(listTestTag)).performScrollToIndex(scrollToIndex) + + assertThatModelIsInMemoryCache(preloadModels[imageIndex + 1]) + } + + // Ignore the preload request because we want to test that the preloader loaded a model + // and not be confused by our UI loading a model. Do not ignore the preload request + // builder in real code! + @Composable + private fun GlidePreloadingData.triggerPreload(index: Int) = this[index].first + + @Composable + private fun rememberOneItemAtATimePreloadingData(): GlidePreloadingData { + return rememberGlidePreloadingData( + data = preloadModels, + preloadImageSize = Target.SIZE_ORIGINAL.toSize(), + numberOfItemsToPreload = 1, + fixedVisibleItemCount = 1, + ) { model, requestBuilder -> + requestBuilder.load(model).removeTheme() + } + } + + private fun assertThatModelIsInMemoryCache(@DrawableRes model: Int) { + // Wait for previous async image loads to finish + glideComposeRule.waitForIdle() + val nextPreloadModel: Drawable = + Glide.with(context).load(model).removeTheme().onlyRetrieveFromCache(true).submit().get() + assertThat(nextPreloadModel).isNotNull() + } + + // We're loading the same resource across two different Contexts. One is the Context from the + // instrumentation package, the other is the package under test. Each Context has it's own + // Theme, + // neither of which are equal to each other. So that we can verify an item is loaded into + // memory, + // we remove the themes from all requests that we need to have matching cache keys. + private fun RequestBuilder.removeTheme() = theme(null) + + private companion object { + const val model = android.R.drawable.star_big_on + + // Use different preload and non-preload models so that we can assert on which items are + // preloaded and not loaded by the list. This is bad practice in production code and would + // waste + // resources while doing nothing useful in a real app. + val preloadModels = + listOf( + android.R.drawable.btn_minus, + android.R.drawable.btn_radio, + android.R.drawable.btn_star, + ) + + const val listTestTag = "listTestTag" + + fun imageContentDescription(index: Int) = "Image $index" + } +} + +private fun Int.toSize() = this.toFloat().let { Size(it, it) } diff --git a/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/test/GlideComposeRule.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/test/GlideComposeRule.kt new file mode 100644 index 0000000000..bade4c4b65 --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/test/GlideComposeRule.kt @@ -0,0 +1,31 @@ +package com.bumptech.glide.integration.compose.test + +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import com.bumptech.glide.load.engine.executor.GlideIdlingResourceInit +import com.bumptech.glide.testutil.TearDownGlide +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * Merges [TearDownGlide], [ComposeContentTestRule] and [GlideIdlingResourceInit] into a single + * helper rule that's common across (most of) Glide's compose integration tests. + */ +class GlideComposeRule(private val composeRule: ComposeContentTestRule = createComposeRule()) : + TestRule, ComposeContentTestRule by composeRule { + private val rules = RuleChain.outerRule(TearDownGlide()).around(composeRule) + + override fun apply(base: Statement?, description: Description?): Statement { + return rules.apply( + object : Statement() { + override fun evaluate() { + GlideIdlingResourceInit.initGlide(this@GlideComposeRule) + base?.evaluate() + } + }, + description, + ) + } +} diff --git a/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/test/expectations.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/test/expectations.kt new file mode 100644 index 0000000000..b3d7995dec --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/test/expectations.kt @@ -0,0 +1,86 @@ +@file:OptIn(InternalGlideApi::class) + +package com.bumptech.glide.integration.compose.test + +import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.util.TypedValue +import androidx.compose.runtime.MutableState +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.test.SemanticsMatcher +import androidx.test.core.app.ApplicationProvider +import com.bumptech.glide.integration.compose.DisplayedDrawableKey +import com.bumptech.glide.integration.ktx.InternalGlideApi +import com.bumptech.glide.integration.ktx.Size +import kotlin.math.roundToInt + +private fun context(): Context = ApplicationProvider.getApplicationContext() + +fun Int.dpToPixels() = + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + this.toFloat(), + Resources.getSystem().displayMetrics, + ) + .roundToInt() + +fun Int.bitmapSize() = context().resources.getDrawable(this, context().theme).size() + +fun Drawable.size() = (this as BitmapDrawable).bitmap.let { Size(it.width, it.height) } + +fun expectDisplayedResource(resourceId: Int) = + expectDisplayedDrawable(context().getDrawable(resourceId)) + +fun Drawable?.bitmapOrThrow(): Bitmap? = if (this == null) null else (this as BitmapDrawable).bitmap + +fun expectDisplayedDrawableSize(expectedSize: Size): SemanticsMatcher = + expectDisplayedDrawable(expectedSize) { it?.size() } + +fun expectDisplayedDrawable(expectedValue: Drawable?): SemanticsMatcher = + expectDisplayedDrawable(expectedValue.bitmapOrThrow(), ::compareBitmaps) { it.bitmapOrThrow() } + +fun expectNoDrawable(): SemanticsMatcher = expectDisplayedDrawable(null) + +private fun compareBitmaps(first: Bitmap?, second: Bitmap?): Boolean { + if (first == null && second == null) { + return true + } + if (first == null || second == null) { + return false + } + return first.sameAs(second) +} + +private fun expectDisplayedDrawable( + expectedValue: ValueT, + compare: (ValueT?, ValueT?) -> Boolean = { first, second -> first == second }, + transform: (Drawable?) -> ValueT, +): SemanticsMatcher = + expectStateValue(DisplayedDrawableKey, expectedValue, compare) { transform(it) } + +private fun expectStateValue( + key: SemanticsPropertyKey>, + expectedValue: TransformedValueT, + compare: (TransformedValueT?, TransformedValueT?) -> Boolean, + transform: (ValueT?) -> TransformedValueT?, +): SemanticsMatcher = + SemanticsMatcher("${key.name} = '$expectedValue'") { + val value = transform(it.config.getOrElseNullable(key) { null }?.value) + if (!compare(value, expectedValue)) { + throw AssertionError("Expected: $expectedValue, but was: $value") + } + true + } + +fun expectSameInstance(expectedDrawable: Drawable) = + SemanticsMatcher("${DisplayedDrawableKey.name} = '$expectedDrawable'") { + val actualValue: Drawable? = + it.config.getOrElseNullable(DisplayedDrawableKey) { null }?.value + if (actualValue !== expectedDrawable) { + throw AssertionError("Expected: $expectedDrawable, but was: $actualValue") + } + true + } diff --git a/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/test/nodes.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/test/nodes.kt new file mode 100644 index 0000000000..f5092c7251 --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/integration/compose/test/nodes.kt @@ -0,0 +1,26 @@ +package com.bumptech.glide.integration.compose.test + +import android.app.Application +import android.graphics.drawable.Drawable +import androidx.annotation.DrawableRes +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.test.core.app.ApplicationProvider + +object Constants { + const val DEFAULT_DESCRIPTION = "test" +} + +fun ComposeContentTestRule.onNodeWithDefaultContentDescription() = + onNodeWithContentDescription(Constants.DEFAULT_DESCRIPTION) + +fun SemanticsNodeInteraction.assertDisplays(@DrawableRes resourceId: Int) = + assertDisplays(ApplicationProvider.getApplicationContext().getDrawable(resourceId)) + +fun SemanticsNodeInteraction.assertDisplays(drawable: Drawable?) = + assert(expectDisplayedDrawable(drawable)) + +fun SemanticsNodeInteraction.assertDisplaysInstance(drawable: Drawable) = + assert(expectSameInstance(drawable)) diff --git a/integration/compose/src/androidTest/java/com/bumptech/glide/load/engine/executor/GlideIdlingResourceInit.kt b/integration/compose/src/androidTest/java/com/bumptech/glide/load/engine/executor/GlideIdlingResourceInit.kt new file mode 100644 index 0000000000..e227ced983 --- /dev/null +++ b/integration/compose/src/androidTest/java/com/bumptech/glide/load/engine/executor/GlideIdlingResourceInit.kt @@ -0,0 +1,41 @@ +package com.bumptech.glide.load.engine.executor + +import androidx.compose.ui.test.IdlingResource +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.idling.concurrent.IdlingThreadPoolExecutor +import com.bumptech.glide.Glide +import com.bumptech.glide.GlideBuilder +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +object GlideIdlingResourceInit { + + fun initGlide(composeRule: ComposeTestRule) { + val executor = + IdlingThreadPoolExecutor( + "glide_test_thread", + /* corePoolSize = */ 1, + /* maximumPoolSize = */ 1, + /* keepAliveTime = */ 1, + TimeUnit.SECONDS, + LinkedBlockingQueue(), + ) { + Thread(it) + } + composeRule.registerIdlingResource( + object : IdlingResource { + override val isIdleNow: Boolean + get() = executor.isIdleNow + } + ) + val glideExecutor = GlideExecutor(executor) + Glide.init( + ApplicationProvider.getApplicationContext(), + GlideBuilder() + .setSourceExecutor(glideExecutor) + .setAnimationExecutor(glideExecutor) + .setDiskCacheExecutor(glideExecutor), + ) + } +} diff --git a/integration/compose/src/main/java/com/bumptech/glide/integration/compose/ExperimentalGlideComposeApi.kt b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/ExperimentalGlideComposeApi.kt new file mode 100644 index 0000000000..8727c9412f --- /dev/null +++ b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/ExperimentalGlideComposeApi.kt @@ -0,0 +1,11 @@ +package com.bumptech.glide.integration.compose + +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = + "Glide's Compose integration is experimental. APIs may change or be removed without" + + " warning.", +) +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +public annotation class ExperimentalGlideComposeApi diff --git a/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlideImage.kt b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlideImage.kt new file mode 100644 index 0000000000..9349e9b675 --- /dev/null +++ b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlideImage.kt @@ -0,0 +1,373 @@ +package com.bumptech.glide.integration.compose + +import android.graphics.drawable.Drawable +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.semantics +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.RequestManager +import com.bumptech.glide.integration.ktx.AsyncGlideSize +import com.bumptech.glide.integration.ktx.ExperimentGlideFlows +import com.bumptech.glide.integration.ktx.ImmediateGlideSize +import com.bumptech.glide.integration.ktx.InternalGlideApi +import com.bumptech.glide.integration.ktx.ResolvableGlideSize +import com.bumptech.glide.integration.ktx.Size +import com.bumptech.glide.integration.ktx.Status +import com.google.accompanist.drawablepainter.rememberDrawablePainter + +/** Mutates and returns the given [RequestBuilder] to apply relevant options. */ +public typealias RequestBuilderTransform = (RequestBuilder) -> RequestBuilder + +/** + * Start a request by passing [model] to [RequestBuilder.load] using the given [requestManager] and + * then applying the [requestBuilderTransform] function to add options or apply mutations if the + * caller desires. + * + * [alignment], [contentScale], [alpha], [colorFilter] and [contentDescription] have the same + * defaults (if any) and function identically to the parameters in [Image]. + * + * If you want to restrict the size of this [Composable], use the given [modifier]. If you'd like to + * force the size of the pixels you load to be different than the display area, use + * [RequestBuilder.override]. Often you can get better performance by setting an explicit size so + * that we do not have to wait for layout to fetch the image. If the size set via the [modifier] is + * dependent on the content, Glide will probably end up loading the image using + * [com.bumptech.glide.request.target.Target.SIZE_ORIGINAL]. Avoid `SIZE_ORIGINAL`, implicitly or + * explicitly if you can. You may end up loading a substantially larger image than you need, which + * will increase memory usage and may also increase latency. + * + * If you provide your own [requestManager] rather than using this method's default, consider using + * [remember] at a higher level to avoid some amount of overhead of retrieving it each + * re-composition. + * + * This method will inspect [contentScale] and apply a matching transformation if one exists. Any + * automatically applied transformation can be overridden using [requestBuilderTransform]. Either + * apply a specific transformation instead, or use [RequestBuilder.dontTransform]] + * + * Transitions set via [RequestBuilder.transition] are currently ignored. + * + * Note - this method is likely to change while we work on improving the API. Transitions are one + * significant unexplored area. It's also possible we'll try and remove the [RequestBuilder] from + * the direct API and instead allow all options to be set directly in the method. + * + * [requestBuilderTransform] is overridden by any overlapping parameter defined in this method if + * that parameter is non-null. For example, [loading] and [failure], if non-null will be used in + * place of any placeholder set by [requestBuilderTransform] using [RequestBuilder.placeholder] or + * [RequestBuilder.error]. + * + * @param loading A [Placeholder] that will be displayed while the request is loading. Specifically + * it's used if the request is cleared ([com.bumptech.glide.request.target.Target.onLoadCleared]) + * or loading ([com.bumptech.glide.request.target.Target.onLoadStarted]. There's a subtle + * difference in behavior depending on which type of [Placeholder] you use. The resource and + * `Drawable` variants will be displayed if the request fails and no other failure handling is + * specified, but the `Composable` will not. + * @param failure A [Placeholder] that will be displayed if the request fails. Specifically it's + * used when [com.bumptech.glide.request.target.Target.onLoadFailed] is called. If + * [RequestBuilder.error] is called in [requestBuilderTransform] with a valid [RequestBuilder] (as + * opposed to resource id or [Drawable]), this [Placeholder] will not be used unless the `error` + * [RequestBuilder] also fails. This parameter does not override error [RequestBuilder]s, only + * error resource ids and/or [Drawable]s. + */ +// TODO(judds): the API here is not particularly composeesque, we should consider alternatives +// to RequestBuilder (though thumbnail() may make that a challenge). +// TODO(judds): Consider how to deal with transitions. +@ExperimentalGlideComposeApi +@OptIn(InternalGlideApi::class) +@Composable +public fun GlideImage( + model: Any?, + contentDescription: String?, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + // TODO(judds): Consider using separate GlideImage* methods instead of sealed classes. + // See http://shortn/_x79pjkMZIH for an internal discussion. + loading: Placeholder? = null, + failure: Placeholder? = null, + // TODO(judds): Consider defaulting to load the model here instead of always doing so below. + requestBuilderTransform: RequestBuilderTransform = { it }, +) { + val requestManager: RequestManager = + LocalContext.current.let { remember(it) { Glide.with(it) } } + val requestBuilder = + rememberRequestBuilderWithDefaults( + model, + requestManager, + requestBuilderTransform, + contentScale, + ) + .let { loading?.apply(it::placeholder, it::placeholder) ?: it } + .let { failure?.apply(it::error, it::error) ?: it } + + val overrideSize: Size? = requestBuilder.overrideSize() + val (size, finalModifier) = rememberSizeAndModifier(overrideSize, modifier) + + // TODO(judds): It seems like we should be able to use the production paths for + // resource / drawables as well as Composables. It's not totally clear what part of the prod + // code + // isn't supported. + if (LocalInspectionMode.current && loading?.isResourceOrDrawable() == true) { + PreviewResourceOrDrawable(loading, contentDescription, modifier) + return + } + + SizedGlideImage( + requestBuilder = requestBuilder, + size = size, + modifier = finalModifier, + contentDescription = contentDescription, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + placeholder = loading?.maybeComposable(), + failure = failure?.maybeComposable(), + ) +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +private fun PreviewResourceOrDrawable( + loading: Placeholder, + contentDescription: String?, + modifier: Modifier, +) { + val drawable = + when (loading) { + is Placeholder.OfDrawable -> loading.drawable + is Placeholder.OfResourceId -> LocalContext.current.getDrawable(loading.resourceId) + is Placeholder.OfComposable -> + throw IllegalArgumentException( + "Composables should go through the production codepath" + ) + } + Image( + painter = rememberDrawablePainter(drawable), + modifier = modifier, + contentDescription = contentDescription, + ) +} + +/** + * Used to specify a [Drawable] to use in conjunction with [GlideImage]'s `loading` or `failure` + * parameters. + * + * Ideally [drawable] is non-null, but because [android.content.Context.getDrawable] can return + * null, we allow it here. `placeholder(null)` has the same override behavior as if a non-null + * `Drawable` were provided. + */ +@ExperimentalGlideComposeApi +public fun placeholder(drawable: Drawable?): Placeholder = Placeholder.OfDrawable(drawable) + +/** + * Used to specify a resource id to use in conjunction with [GlideImage]'s `loading` or `failure` + * parameters. + * + * In addition to being slightly simpler than manually fetching a [Drawable] and passing it to + * [placeholder], this method can be more efficient because the [Drawable] will only be loaded when + * needed. + */ +@ExperimentalGlideComposeApi +public fun placeholder(@DrawableRes resourceId: Int): Placeholder = + Placeholder.OfResourceId(resourceId) + +/** + * Used to specify a [Composable] function to use in conjunction with [GlideImage]'s `loading` or + * `failure` parameter. + * + * Providing a nested [GlideImage] is not recommended. Use [RequestBuilder.thumbnail] or + * [RequestBuilder.error] as an alternative. + */ +@ExperimentalGlideComposeApi +public fun placeholder(composable: @Composable () -> Unit): Placeholder = + Placeholder.OfComposable(composable) + +/** + * Content to display during a particular state of a Glide Request, for example while the request is + * loading or if the request fails. + * + * `of(Drawable)` and `of(resourceId)` trigger fewer recompositions than `of(@Composable () -> + * Unit)` so you should only use the Composable variant if you require something more complex than a + * simple color or a static image. + * + * `of(@Composable () -> Unit)` will display the [Composable] inside a [Box] whose modifier is the + * one provided to [GlideImage]. Doing so allows Glide to infer the requested size if one is not + * explicitly specified on the request itself. + */ +@ExperimentalGlideComposeApi +public sealed class Placeholder { + internal class OfDrawable(internal val drawable: Drawable?) : Placeholder() + + internal class OfResourceId(@DrawableRes internal val resourceId: Int) : Placeholder() + + internal class OfComposable(internal val composable: @Composable () -> Unit) : Placeholder() + + internal fun isResourceOrDrawable() = + when (this) { + is OfDrawable -> true + is OfResourceId -> true + is OfComposable -> false + } + + internal fun maybeComposable(): (@Composable () -> Unit)? = + when (this) { + is OfComposable -> this.composable + else -> null + } + + internal fun apply( + resource: (Int) -> RequestBuilder, + drawable: (Drawable?) -> RequestBuilder, + ): RequestBuilder = + when (this) { + is OfDrawable -> drawable(this.drawable) + is OfResourceId -> resource(this.resourceId) + // Clear out any previously set placeholder. + else -> drawable(null) + } +} + +@OptIn(InternalGlideApi::class) +private data class SizeAndModifier(val size: ResolvableGlideSize, val modifier: Modifier) + +@OptIn(InternalGlideApi::class) +@Composable +private fun rememberSizeAndModifier(overrideSize: Size?, modifier: Modifier) = + remember(overrideSize, modifier) { + if (overrideSize != null) { + SizeAndModifier(ImmediateGlideSize(overrideSize), modifier) + } else { + val sizeObserver = SizeObserver() + SizeAndModifier( + AsyncGlideSize(sizeObserver::getSize), + modifier.sizeObservingModifier(sizeObserver), + ) + } + } + +@Composable +private fun rememberRequestBuilderWithDefaults( + model: Any?, + requestManager: RequestManager, + requestBuilderTransform: RequestBuilderTransform, + contentScale: ContentScale, +) = + remember(model, requestManager, requestBuilderTransform, contentScale) { + requestBuilderTransform(requestManager.load(model).contentScaleTransform(contentScale)) + } + +private fun RequestBuilder.contentScaleTransform( + contentScale: ContentScale +): RequestBuilder { + return when (contentScale) { + ContentScale.Crop -> { + optionalCenterCrop() + } + ContentScale.Inside, + ContentScale.Fit -> { + // Outside compose, glide would use fitCenter() for FIT. But that's probably not a good + // decision given how unimportant Bitmap re-use is relative to minimizing texture sizes + // now. + // So instead we'll do something different and prefer not to upscale, which means using + // centerInside(). The UI can still scale the view even if the Bitmap is smaller. + optionalCenterInside() + } + else -> { + this + } + } + // TODO(judds): Think about how to handle the various fills +} + +@OptIn(InternalGlideApi::class, ExperimentGlideFlows::class) +@Composable +private fun SizedGlideImage( + requestBuilder: RequestBuilder, + size: ResolvableGlideSize, + modifier: Modifier, + contentDescription: String?, + alignment: Alignment, + contentScale: ContentScale, + alpha: Float, + colorFilter: ColorFilter?, + placeholder: @Composable (() -> Unit)?, + failure: @Composable (() -> Unit)?, +) { + // Use a Box so we can infer the size if the request doesn't have an explicit size. + @Composable fun @Composable () -> Unit.boxed() = Box(modifier = modifier) { this@boxed() } + + val painter = rememberGlidePainter(requestBuilder = requestBuilder, size = size) + if (placeholder != null && painter.status.showPlaceholder()) { + placeholder.boxed() + } else if (failure != null && painter.status == Status.FAILED) { + failure.boxed() + } else { + Image( + painter = painter, + contentDescription = contentDescription, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + modifier = + modifier.then(Modifier.semantics { displayedDrawable = painter.currentDrawable }), + ) + } +} + +@OptIn(ExperimentGlideFlows::class) +private fun Status.showPlaceholder(): Boolean = + when (this) { + Status.RUNNING -> true + Status.CLEARED -> true + else -> false + } + +@OptIn(InternalGlideApi::class) +@Composable +private fun rememberGlidePainter( + requestBuilder: RequestBuilder, + size: ResolvableGlideSize, +): GlidePainter { + val scope = rememberCoroutineScope() + val lifecycleOwner = LocalLifecycleOwner.current + // TODO(judds): Calling onRemembered here manually might make a minor improvement in how quickly + // the image load is started, but it also triggers a recomposition. I can't figure out why it + // triggers a recomposition + return remember(requestBuilder, size, lifecycleOwner) { + GlidePainter(requestBuilder, size, scope, lifecycleOwner) + } +} + +@OptIn(InternalGlideApi::class) +private fun Modifier.sizeObservingModifier(sizeObserver: SizeObserver): Modifier = + this.layout { measurable, constraints -> + val inferredSize = constraints.inferredGlideSize() + if (inferredSize != null) { + sizeObserver.setSize(inferredSize) + } + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + } + +internal val DisplayedDrawableKey = + SemanticsPropertyKey>("DisplayedDrawable") +internal var SemanticsPropertyReceiver.displayedDrawable by DisplayedDrawableKey diff --git a/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlidePainter.kt b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlidePainter.kt new file mode 100644 index 0000000000..8497bc0fc1 --- /dev/null +++ b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/GlidePainter.kt @@ -0,0 +1,162 @@ +package com.bumptech.glide.integration.compose + +import android.graphics.drawable.Animatable +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.RememberObserver +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.integration.ktx.ExperimentGlideFlows +import com.bumptech.glide.integration.ktx.InternalGlideApi +import com.bumptech.glide.integration.ktx.Placeholder +import com.bumptech.glide.integration.ktx.ResolvableGlideSize +import com.bumptech.glide.integration.ktx.Resource +import com.bumptech.glide.integration.ktx.Status +import com.bumptech.glide.integration.ktx.flowResolvable +import com.google.accompanist.drawablepainter.DrawablePainter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus + +// This class is inspired by a similar implementation in the excellent Coil library +// (https://github.com/coil-kt/coil), specifically: +// https://github.com/coil-kt/coil/blob/main/coil-compose-base/src/main/java/coil/compose/AsyncImagePainter.kt +@Stable +internal class GlidePainter +@OptIn(InternalGlideApi::class) +constructor( + private val requestBuilder: RequestBuilder, + private val size: ResolvableGlideSize, + scope: CoroutineScope, + private val lifecycleOwner: LifecycleOwner, +) : Painter(), RememberObserver { + @OptIn(ExperimentGlideFlows::class) + internal var status: Status by mutableStateOf(Status.CLEARED) + internal val currentDrawable: MutableState = mutableStateOf(null) + private var alpha: Float by mutableStateOf(DefaultAlpha) + private var colorFilter: ColorFilter? by mutableStateOf(null) + private var delegate: Painter? by mutableStateOf(null) + private val scope = + scope + SupervisorJob(parent = scope.coroutineContext.job) + Dispatchers.Main.immediate + private var currentJob: Job? = null + + init { + scope.launch { + // If the Lifecycle state is at least STARTED, start the animation. Otherwise, stop the + // animation. + lifecycleOwner.lifecycle.currentStateFlow.collect { + if (it.isAtLeast(Lifecycle.State.STARTED)) { + currentDrawable.value?.let { drawable -> + if (drawable is Animatable) { + drawable.start() + } + } + } else { + currentDrawable.value?.let { drawable -> + if (drawable is Animatable) { + drawable.stop() + } + } + } + } + } + } + + override val intrinsicSize: Size + get() = delegate?.intrinsicSize ?: Size.Unspecified + + override fun DrawScope.onDraw() { + delegate?.apply { draw(size, alpha, colorFilter) } + } + + override fun onAbandoned() { + (delegate as? RememberObserver)?.onAbandoned() + } + + override fun onForgotten() { + (delegate as? RememberObserver)?.onForgotten() + currentJob?.cancel() + currentJob = null + currentDrawable.value = null + delegate = null + } + + override fun onRemembered() { + (delegate as? RememberObserver)?.onRemembered() + if (currentJob == null) { + currentJob = launchRequest() + } + // In case the onRemembered is called after the lifecycle onStop, it will start the + // animation, + // stop it here again. + if (!lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + currentDrawable.value?.let { drawable -> + if (drawable is Animatable) { + drawable.stop() + } + } + } + } + + @OptIn(ExperimentGlideFlows::class, InternalGlideApi::class) + private fun launchRequest() = + this.scope.launch { + requestBuilder.flowResolvable(size).collect { + updateDelegate( + when (it) { + is Resource -> it.resource + is Placeholder -> it.placeholder + } + ) + status = it.status + } + } + + private fun Drawable.toPainter() = + when (this) { + is BitmapDrawable -> BitmapPainter(bitmap.asImageBitmap()) + is ColorDrawable -> ColorPainter(Color(color)) + else -> DrawablePainter(mutate()) + } + + private fun updateDelegate(drawable: Drawable?) { + val newDelegate = drawable?.toPainter() + val oldDelegate = delegate + if (newDelegate !== oldDelegate) { + (oldDelegate as? RememberObserver)?.onForgotten() + (newDelegate as? RememberObserver)?.onRemembered() + currentDrawable.value = drawable + delegate = newDelegate + } + } + + override fun applyAlpha(alpha: Float): Boolean { + this.alpha = alpha + return true + } + + override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { + this.colorFilter = colorFilter + return true + } +} diff --git a/integration/compose/src/main/java/com/bumptech/glide/integration/compose/Preload.kt b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/Preload.kt new file mode 100644 index 0000000000..378a4c4ad7 --- /dev/null +++ b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/Preload.kt @@ -0,0 +1,240 @@ +package com.bumptech.glide.integration.compose + +import android.graphics.drawable.Drawable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.platform.LocalContext +import com.bumptech.glide.Glide +import com.bumptech.glide.ListPreloader +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.RequestManager + +private const val DEFAULT_ITEMS_TO_PRELOAD = 10 + +/** + * Preloads ahead of the data access position on the returned [GlidePreloadingData], similar to + * [ListPreloader] and [com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader]. + * + * The only time this API is useful is when your UI also loads an item with exactly the same + * options, model and size. You can ensure you're doing so by using the [RequestBuilder] returned by + * [GlidePreloadingData.get] + * + * Typical usage will look something like this: + * ``` + * val glidePreloadingData = + * rememberGlidePreloadingData(myDataList, THUMBNAIL_SIZE) { myDataItem, requestBuilder -> + * // THUMBNAIL_SIZE is applied for you, but .load() is not because determining the model from + * // the underlying data isn't trivial. Don't forget to call .load()! + * requestBuilder.load(myDataItem.url) + * } + * + * LazyRow(...) { + * item { Text(text = "Header") } + * items(glidePreloadingData.size) { index -> + * val (myDataItem, preloadRequest) = glidePreloadingData[index] + * GlideImage(model = item.url, contentDescription = item.description, ...) { primaryRequest -> + * primaryRequest.thumbnail(preloadRequest) + * } + * } + * } + * ``` + * + * Note that preloading will not occur until the first access of `glidePreloadingData`. If you have + * multiple disjoint data sets that you'd like to preload, or have some number of preceding header + * rows prior to your first image, you can optionally add a few manual calls to make preloading + * continue smoothly across data sets. One way you might do so is to call the next data set toward + * the end of the previous data set, e.g.: + * + * ``` + * val itemsToPreload = 15 + * items(firstDataSet.size) { index -> + * ... // Do something with first data set. + * + * // Then as you get to the end of the first data set, start preloading the next data set + * manually + * if (index >= firstDataSet.size - itemsToPreload) { + * nextDataSet[itemsToPreload - (firstDataSet.size - index)] + * } + * } + * ``` + * + * @param dataSize The total number of items to display and preload. + * @param dataGetter A getter for the item at the given index (ie [List.get]. + * @param preloadImageSize The override size we'll pass to [RequestBuilder.override] . + * @param numberOfItemsToPreload The number of items to preload ahead of the user's current + * position. This should be tested for each application. If the total memory size of the preloaded + * images exceeds the memory cache size, preloading for a lazy list is not effective. However if + * you preload too few things, the buffer may be small enough that images are not available when + * they could be, so it's always a balancing act. The smaller the preloaded image, the more you + * can preload. + * @param fixedVisibleItemCount The number of visible items. In some cases this can vary widely in + * which case you can leave this value `null`. If the number of visible items is always one or + * two, it might make sense to just set this to the larger of the two to reduce churn in the + * preloader. + * @param requestBuilderTransform See [ListPreloader.PreloadModelProvider.getPreloadRequestBuilder]. + * You should call [RequestBuilder.load] on the given `item` so that any type specific options + * applied the matching [RequestManager.load] method are applied identically to the preload + * request. Remember that the request produced by this transform must exactly match the request + * made in your non-preload request for preloading to be useful. + */ +@Composable +public fun rememberGlidePreloadingData( + dataSize: Int, + dataGetter: (Int) -> DataT, + preloadImageSize: Size, + numberOfItemsToPreload: Int = DEFAULT_ITEMS_TO_PRELOAD, + fixedVisibleItemCount: Int? = null, + requestBuilderTransform: PreloadRequestBuilderTransform, +): GlidePreloadingData { + val requestManager = LocalContext.current.let { remember(it) { Glide.with(it) } } + return remember( + requestManager, + dataSize, + dataGetter, + preloadImageSize, + numberOfItemsToPreload, + fixedVisibleItemCount, + requestBuilderTransform, + ) { + val preloaderData = + PreloaderData(dataSize, dataGetter, requestBuilderTransform, preloadImageSize) + val preloader = + ListPreloader( + requestManager, + PreloadModelProvider(requestManager, preloaderData), + PreloadDimensionsProvider(preloaderData), + numberOfItemsToPreload, + ) + PreloadDataImpl( + dataSize, + dataGetter, + requestManager, + preloadImageSize, + fixedVisibleItemCount, + preloader, + requestBuilderTransform, + ) + } +} + +/** + * A helper for [rememberGlidePreloadingData] that accepts a [List]. See the more general equivalent + * for details. + */ +@Composable +public fun rememberGlidePreloadingData( + data: List, + preloadImageSize: Size, + numberOfItemsToPreload: Int = DEFAULT_ITEMS_TO_PRELOAD, + fixedVisibleItemCount: Int? = null, + requestBuilderTransform: PreloadRequestBuilderTransform, +): GlidePreloadingData { + return rememberGlidePreloadingData( + dataSize = data.size, + dataGetter = data::get, + preloadImageSize = preloadImageSize, + numberOfItemsToPreload = numberOfItemsToPreload, + fixedVisibleItemCount = fixedVisibleItemCount, + requestBuilderTransform = requestBuilderTransform, + ) +} + +private data class PreloaderData( + val dataSize: Int, + val dataAccessor: (Int) -> DataT, + val requestBuilderTransform: PreloadRequestBuilderTransform, + val size: Size, +) { + fun preloadRequests(requestManager: RequestManager, item: DataT): RequestBuilder { + return requestBuilderTransform(item, requestManager.asDrawable()) + } +} + +/** + * Wraps a set of data, triggers image preloads based on the positions provided to [get] and exposes + * the data and the preload [RequestBuilder]. + */ +public interface GlidePreloadingData { + /** The total number of items in the data set. */ + public val size: Int + + /** + * Returns the [DataT] at a given index in the data and a [RequestBuilder] that will trigger a + * request that exactly matches the preload request for this index. + * + * The returned [RequestBuilder] should always be used to display the item at the given index. + * Otherwise the preload request triggered by this call is likely useless work. The + * [RequestBuilder] can either be used as the primary request, or more likely, passed as the + * [RequestBuilder.thumbnail] to a higher resolution request. + * + * This method has side affects! Calling it will trigger preloads based on the given [index]. + * Preloading assumes sequential access in a manner that matches what the user will see. If you + * need to look up data at indices for other reasons, use the underlying data source directly so + * that you do not confuse the preloader. Only use this method when obtaining data to display to + * the user. + */ + @Composable public operator fun get(index: Int): Pair> +} + +private class PreloadDataImpl( + override val size: Int, + private val indexToData: (Int) -> DataT, + private val requestManager: RequestManager, + private val preloadImageSize: Size, + private val fixedVisibleItemCount: Int?, + private val preloader: ListPreloader, + private val requestBuilderTransform: PreloadRequestBuilderTransform, +) : GlidePreloadingData { + + @Composable + override fun get(index: Int): Pair> { + val item = indexToData(index) + val requestBuilder = + requestBuilderTransform( + item, + requestManager + .asDrawable() + .override(preloadImageSize.width.toInt(), preloadImageSize.height.toInt()), + ) + + LaunchedEffect(preloader, preloadImageSize, requestBuilderTransform, indexToData, index) { + preloader.onScroll(/* absListView= */ null, index, fixedVisibleItemCount ?: 1, size) + } + return item to requestBuilder + } +} + +private class PreloadDimensionsProvider( + private val updatedData: PreloaderData +) : ListPreloader.PreloadSizeProvider { + override fun getPreloadSize(item: DataT, adapterPosition: Int, perItemPosition: Int): IntArray = + updatedData.size.toIntArray() +} + +private fun Size.toIntArray() = intArrayOf(width.toInt(), height.toInt()) + +private class PreloadModelProvider( + private val requestManager: RequestManager, + private val data: PreloaderData, +) : ListPreloader.PreloadModelProvider { + + override fun getPreloadItems(position: Int): MutableList { + return mutableListOf(data.dataAccessor(position)) + } + + override fun getPreloadRequestBuilder(item: DataT): RequestBuilder<*> { + return data.preloadRequests(requestManager, item) + } +} + +/** + * Provides the data to load and a [RequestBuilder] to load it with. + * + * You must at least call [RequestBuilder.load] with the appropriate model extracted from `item` on + * the given `requestBuilder`. You can also optionally call any other methods available on + * `requestBuilder` to customize your load. + */ +public typealias PreloadRequestBuilderTransform = + (item: DataTypeT, requestBuilder: RequestBuilder) -> RequestBuilder diff --git a/integration/compose/src/main/java/com/bumptech/glide/integration/compose/Sizes.kt b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/Sizes.kt new file mode 100644 index 0000000000..122f96fc64 --- /dev/null +++ b/integration/compose/src/main/java/com/bumptech/glide/integration/compose/Sizes.kt @@ -0,0 +1,42 @@ +@file:OptIn(InternalGlideApi::class) + +package com.bumptech.glide.integration.compose + +import androidx.compose.ui.unit.Constraints +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.integration.ktx.InternalGlideApi +import com.bumptech.glide.integration.ktx.Size +import com.bumptech.glide.integration.ktx.isValidGlideDimension +import com.bumptech.glide.request.target.Target +import kotlinx.coroutines.CompletableDeferred + +internal class SizeObserver { + private val size = CompletableDeferred() + + fun setSize(size: Size) { + this.size.complete(size) + } + + suspend fun getSize(): Size { + return size.await() + } +} + +internal fun RequestBuilder.overrideSize(): Size? = + if (isOverrideSizeSet()) { + Size(overrideWidth, overrideHeight) + } else { + null + } + +internal fun RequestBuilder.isOverrideSizeSet(): Boolean = + overrideWidth.isValidGlideDimension() && overrideHeight.isValidGlideDimension() + +internal fun Constraints.inferredGlideSize(): Size? { + val width = if (hasBoundedWidth) maxWidth else Target.SIZE_ORIGINAL + val height = if (hasBoundedHeight) maxHeight else Target.SIZE_ORIGINAL + if (!width.isValidGlideDimension() || !height.isValidGlideDimension()) { + return null + } + return Size(width, height) +} diff --git a/integration/compose/src/test/java/com/bumptech/glide/integration/compose/GlideImageTest.kt b/integration/compose/src/test/java/com/bumptech/glide/integration/compose/GlideImageTest.kt new file mode 100644 index 0000000000..4c7a5c89a3 --- /dev/null +++ b/integration/compose/src/test/java/com/bumptech/glide/integration/compose/GlideImageTest.kt @@ -0,0 +1,32 @@ +package com.bumptech.glide.integration.compose + +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalGlideComposeApi::class) +@RunWith(AndroidJUnit4::class) +class GlideImageTest { + + @get:Rule(order = 1) val composeRule = createComposeRule() + + @Test + fun glideImage_zeroWidthFillBounds_doesNotCrash() { + composeRule.setContent { + GlideImage( + model = null, + contentDescription = null, + modifier = Modifier.width(0.dp).heightIn(0.dp, 100.dp), + contentScale = ContentScale.FillBounds, + loading = placeholder(android.R.drawable.star_on), + ) + } + } +} diff --git a/integration/compose/src/test/resources/robolectric.properties b/integration/compose/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..189df8cfae --- /dev/null +++ b/integration/compose/src/test/resources/robolectric.properties @@ -0,0 +1,4 @@ +# Cap Robolectric target SDK to 34 because the active Robolectric 4.11.1 version +# in this project only supports simulation up to Android SDK 34 (maxSdkVersion=34). +# Using targetSdkVersion 35/36 causes sandbox initialization failures and GHA CI network hangs. +sdk=34 diff --git a/integration/concurrent/build.gradle b/integration/concurrent/build.gradle deleted file mode 100644 index 2ecca3222b..0000000000 --- a/integration/concurrent/build.gradle +++ /dev/null @@ -1,32 +0,0 @@ -apply plugin: 'com.android.library' - -dependencies { - implementation project(':library') - implementation "com.google.guava:guava:${GUAVA_VERSION}" - implementation "androidx.concurrent:concurrent-futures:${ANDROID_X_FUTURES_VERSION}" - - testImplementation project(':mocks') - testImplementation "androidx.legacy:legacy-support-v4:${ANDROID_X_VERSION}" - testImplementation "androidx.test:core:${ANDROID_X_TEST_CORE_VERSION}" - testImplementation "com.google.truth:truth:${TRUTH_VERSION}" - testImplementation "junit:junit:${JUNIT_VERSION}" - testImplementation "org.robolectric:robolectric:${ROBOLECTRIC_VERSION}" -} - -android { - compileSdkVersion COMPILE_SDK_VERSION as int - - defaultConfig { - minSdkVersion MIN_SDK_VERSION as int - targetSdkVersion TARGET_SDK_VERSION as int - - versionName = VERSION_NAME as String - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 - } -} - -apply from: "${rootProject.projectDir}/scripts/upload.gradle" diff --git a/integration/concurrent/build.gradle.kts b/integration/concurrent/build.gradle.kts new file mode 100644 index 0000000000..42d4dc045e --- /dev/null +++ b/integration/concurrent/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "com.bumptech.glide.integration.concurrent" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation(project(":library")) + implementation(libs.guava) + implementation(libs.androidx.futures) + + testImplementation(project(":mocks")) + testImplementation(project(":testutil")) + testImplementation(libs.androidx.test.core) + testImplementation(libs.truth) + testImplementation(libs.junit) + testImplementation(libs.robolectric) +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") \ No newline at end of file diff --git a/integration/concurrent/src/main/AndroidManifest.xml b/integration/concurrent/src/main/AndroidManifest.xml deleted file mode 100644 index 92c8e1f5d7..0000000000 --- a/integration/concurrent/src/main/AndroidManifest.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/integration/concurrent/src/main/java/com/bumptech/glide/integration/concurrent/GlideFutures.java b/integration/concurrent/src/main/java/com/bumptech/glide/integration/concurrent/GlideFutures.java index 628b0bcf14..c244dbbd48 100644 --- a/integration/concurrent/src/main/java/com/bumptech/glide/integration/concurrent/GlideFutures.java +++ b/integration/concurrent/src/main/java/com/bumptech/glide/integration/concurrent/GlideFutures.java @@ -23,6 +23,28 @@ /** Utilities for getting ListenableFutures out of Glide. */ public final class GlideFutures { + /** + * Preloads the resource for {@code builder} and returns a {@link ListenableFuture} that can be + * used to monitor status. + * + *

Shorthand for simply calling {@link #submitAndExecute(RequestManager, RequestBuilder, + * ResourceConsumer, Executor)} with an empty {@code action}. + */ + // Wildcard resource types can't be directly instantiated, we don't need to care about the type + // here. + @SuppressWarnings({"rawtypes", "unchecked"}) + public static ListenableFuture preload( + final RequestManager requestManager, RequestBuilder builder, Executor executor) { + return submitAndExecute( + requestManager, + builder, + new ResourceConsumer() { + @Override + public void act(Object resource) {} + }, + executor); + } + /** * Acts on a resource loaded by Glide. * @@ -40,7 +62,7 @@ public interface ResourceConsumer { * Glide's pool. In particular, if the request is cancelled after the resource is loaded by Glide, * but before {@code action} is run on {@code executor}, the resource will not be returned. We * have the unfortunate choice between unsafely returning resources to the pool immediately when - * cancel is called while they may still be in use via {@link + * cancel is called while they may still be in use via {@code * com.google.common.util.concurrent.ClosingFuture} or occasionally failing to return resources to * the pool. Because failing to return resources to the pool is inefficient, but safe, that's the * route we've chosen. A more sophisticated implementation may allow us to avoid the resource @@ -129,6 +151,8 @@ private static ListenableFuture> submitInternal( final RequestBuilder requestBuilder) { return CallbackToFutureAdapter.getFuture( new Resolver>() { + // Only used for toString + @SuppressWarnings("FutureReturnValueIgnored") @Override public Object attachCompleter(@NonNull Completer> completer) { GlideLoadingListener listener = new GlideLoadingListener<>(completer); @@ -137,11 +161,11 @@ public Object attachCompleter(@NonNull Completer> completer) new Runnable() { @Override public void run() { - futureTarget.cancel(/*mayInterruptIfRunning=*/ true); + futureTarget.cancel(/* mayInterruptIfRunning= */ true); } }, MoreExecutors.directExecutor()); - return listener; + return futureTarget; } }); } @@ -157,14 +181,18 @@ private static final class GlideLoadingListener implements RequestListener @Override public boolean onLoadFailed( - @Nullable GlideException e, Object model, Target target, boolean isFirst) { + @Nullable GlideException e, Object model, @NonNull Target target, boolean isFirst) { completer.setException(e != null ? e : new RuntimeException("Unknown error")); return true; } @Override public boolean onResourceReady( - T resource, Object model, Target target, DataSource dataSource, boolean isFirst) { + @NonNull T resource, + @NonNull Object model, + Target target, + @NonNull DataSource dataSource, + boolean isFirst) { try { completer.set(new TargetAndResult<>(target, resource)); } catch (Throwable t) { diff --git a/integration/concurrent/src/test/java/com/bumptech/glide/integration/concurrent/GlideFuturesTest.java b/integration/concurrent/src/test/java/com/bumptech/glide/integration/concurrent/GlideFuturesTest.java index 4c0eb89117..428520ded5 100644 --- a/integration/concurrent/src/test/java/com/bumptech/glide/integration/concurrent/GlideFuturesTest.java +++ b/integration/concurrent/src/test/java/com/bumptech/glide/integration/concurrent/GlideFuturesTest.java @@ -11,10 +11,17 @@ import androidx.test.core.app.ApplicationProvider; import com.bumptech.glide.Glide; import com.bumptech.glide.GlideBuilder; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.engine.Resource; import com.bumptech.glide.load.engine.executor.GlideExecutor; import com.bumptech.glide.load.engine.executor.MockGlideExecutor; +import com.bumptech.glide.testutil.MockModelLoader; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import java.io.IOException; import java.util.concurrent.ExecutionException; import org.junit.Before; import org.junit.Test; @@ -62,4 +69,46 @@ public void run() throws Throwable { } }); } + + @Test + public void testToString() throws Exception { + Foo model = new Foo(); + SettableFuture bar = SettableFuture.create(); + + Glide.get(app) + .getRegistry() + .prepend( + Bar.class, + Baz.class, + new ResourceDecoder() { + + @Override + public boolean handles(Bar source, Options options) throws IOException { + return true; + } + + @Override + public Resource decode(Bar source, int width, int height, Options options) + throws IOException { + throw new IOException(); + } + }); + MockModelLoader.mockAsync(model, Bar.class, bar); + ListenableFuture future = + GlideFutures.submit( + Glide.with(app) + .as(Baz.class) + .load(model) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE)); + assertThat(future.toString()).contains("Foo"); + future.cancel(true); + assertThat(bar.isCancelled()).isTrue(); + } + + private static final class Foo {} + + private static final class Bar {} + + private static final class Baz {} } diff --git a/integration/cronet/build.gradle b/integration/cronet/build.gradle deleted file mode 100644 index 18356936b1..0000000000 --- a/integration/cronet/build.gradle +++ /dev/null @@ -1,33 +0,0 @@ -apply plugin: 'com.android.library' - -dependencies { - implementation project(':library') - implementation 'com.google.android.gms:play-services-cronet:17.0.0' - implementation "com.google.guava:guava:${GUAVA_VERSION}" - implementation project(':annotation') - - api "androidx.annotation:annotation:${ANDROID_X_ANNOTATION_VERSION}" - - testImplementation "com.google.truth:truth:${TRUTH_VERSION}" - testImplementation "junit:junit:${JUNIT_VERSION}" - testImplementation "org.robolectric:robolectric:${ROBOLECTRIC_VERSION}" - testImplementation "org.mockito:mockito-core:${MOCKITO_VERSION}" -} - -android { - compileSdkVersion COMPILE_SDK_VERSION as int - - defaultConfig { - minSdkVersion 16 as int - targetSdkVersion TARGET_SDK_VERSION as int - - versionName VERSION_NAME as String - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 - } -} - -apply from: "${rootProject.projectDir}/scripts/upload.gradle" diff --git a/integration/cronet/build.gradle.kts b/integration/cronet/build.gradle.kts new file mode 100644 index 0000000000..126c7b90f4 --- /dev/null +++ b/integration/cronet/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { id("com.android.library") } + +android { + namespace = "com.bumptech.glide.integration.cronet" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { minSdk = libs.versions.min.sdk.version.get().toInt() } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation(project(":library")) + implementation(libs.cronet) + implementation(libs.guava) + implementation(project(":annotation")) + annotationProcessor(project(":annotation:compiler")) + + api(libs.androidx.annotation) + + testImplementation(libs.truth) + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.mockito.core) +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") diff --git a/integration/cronet/src/main/AndroidManifest.xml b/integration/cronet/src/main/AndroidManifest.xml index e13bb2f8ae..9695d0b6ba 100644 --- a/integration/cronet/src/main/AndroidManifest.xml +++ b/integration/cronet/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + + + mBuffers; - private final AtomicBoolean mIsCoalesced = new AtomicBoolean(false); + private static final int DEFAULT_BUFFER_SIZE = 16384; + private final Queue buffers; + private final AtomicBoolean isCoalesced = new AtomicBoolean(false); public static Builder builder() { return new Builder(); @@ -32,23 +33,23 @@ public static Builder builder() { * request.read(builder.getNextBuffer(buffer)); } } */ public static final class Builder { - private ArrayDeque mBuffers = new ArrayDeque<>(); + private ArrayDeque buffers = new ArrayDeque<>(); private RuntimeException whenClosed; private Builder() {} /** Returns the next buffer to write data into. */ public ByteBuffer getNextBuffer(ByteBuffer lastBuffer) { - if (mBuffers == null) { + if (buffers == null) { throw new RuntimeException(whenClosed); } - if (lastBuffer != mBuffers.peekLast()) { - mBuffers.addLast(lastBuffer); + if (lastBuffer != buffers.peekLast()) { + buffers.addLast(lastBuffer); } if (lastBuffer.hasRemaining()) { return lastBuffer; } else { - return ByteBuffer.allocateDirect(8096); + return ByteBuffer.allocateDirect(DEFAULT_BUFFER_SIZE); } } @@ -62,6 +63,7 @@ public ByteBuffer getFirstBuffer(UrlResponseInfo info) { return ByteBuffer.allocateDirect((int) Math.min(bufferSizeHeuristic(info), 524288)); } + @SuppressWarnings("checkstyle:UnnecessaryParentheses") // Readability private static long bufferSizeHeuristic(UrlResponseInfo info) { final Map> headers = info.getAllHeaders(); if (headers.containsKey(CONTENT_LENGTH)) { @@ -86,21 +88,21 @@ private static long bufferSizeHeuristic(UrlResponseInfo info) { // No content-length. This means we're either being sent a chunked response, or the // java stack stripped content length because of transparent gzip. In either case we really // have no idea, and so we fall back to a reasonable guess. - return 8192; + return DEFAULT_BUFFER_SIZE; } } public BufferQueue build() { whenClosed = new RuntimeException(); - final ArrayDeque buffers = mBuffers; - mBuffers = null; + final ArrayDeque buffers = this.buffers; + this.buffers = null; return new BufferQueue(buffers); } } private BufferQueue(Queue buffers) { - mBuffers = buffers; - for (ByteBuffer buffer : mBuffers) { + this.buffers = buffers; + for (ByteBuffer buffer : this.buffers) { buffer.flip(); } } @@ -108,18 +110,18 @@ private BufferQueue(Queue buffers) { /** Returns the response body as a single contiguous buffer. */ public ByteBuffer coalesceToBuffer() { markCoalesced(); - if (mBuffers.size() == 0) { + if (buffers.size() == 0) { return ByteBuffer.allocateDirect(0); - } else if (mBuffers.size() == 1) { - return mBuffers.remove(); + } else if (buffers.size() == 1) { + return buffers.remove(); } else { int size = 0; - for (ByteBuffer buffer : mBuffers) { + for (ByteBuffer buffer : buffers) { size += buffer.remaining(); } ByteBuffer result = ByteBuffer.allocateDirect(size); - while (!mBuffers.isEmpty()) { - result.put(mBuffers.remove()); + while (!buffers.isEmpty()) { + result.put(buffers.remove()); } result.flip(); return result; @@ -127,7 +129,7 @@ public ByteBuffer coalesceToBuffer() { } private void markCoalesced() { - if (!mIsCoalesced.compareAndSet(false, true)) { + if (!isCoalesced.compareAndSet(false, true)) { throw new IllegalStateException("This BufferQueue has already been consumed"); } } diff --git a/integration/cronet/src/main/java/com/bumptech/glide/integration/cronet/ByteBufferParser.java b/integration/cronet/src/main/java/com/bumptech/glide/integration/cronet/ByteBufferParser.java index fef4319a03..733251a11e 100644 --- a/integration/cronet/src/main/java/com/bumptech/glide/integration/cronet/ByteBufferParser.java +++ b/integration/cronet/src/main/java/com/bumptech/glide/integration/cronet/ByteBufferParser.java @@ -10,6 +10,7 @@ interface ByteBufferParser { /** Returns the required type of data parsed from the given {@link ByteBuffer}. */ T parse(ByteBuffer byteBuffer); + /** Returns the {@link Class} of the data that will be parsed from {@link ByteBuffer}s. */ Class getDataClass(); } diff --git a/integration/cronet/src/main/java/com/bumptech/glide/integration/cronet/ChromiumRequestSerializer.java b/integration/cronet/src/main/java/com/bumptech/glide/integration/cronet/ChromiumRequestSerializer.java index 4daae3d608..6aa0ead043 100644 --- a/integration/cronet/src/main/java/com/bumptech/glide/integration/cronet/ChromiumRequestSerializer.java +++ b/integration/cronet/src/main/java/com/bumptech/glide/integration/cronet/ChromiumRequestSerializer.java @@ -91,14 +91,29 @@ public final int compareTo(PriorityRunnable another) { GLIDE_TO_CHROMIUM_PRIORITY.put(Priority.LOW, UrlRequest.Builder.REQUEST_PRIORITY_LOWEST); } - private final JobPool jobPool = new JobPool(); + private final JobPool jobPool; private final Map jobs = new HashMap<>(); private final CronetRequestFactory requestFactory; @Nullable private final DataLogger dataLogger; - ChromiumRequestSerializer(CronetRequestFactory requestFactory, @Nullable DataLogger dataLogger) { + ChromiumRequestSerializer( + CronetRequestFactory requestFactory, + @Nullable DataLogger dataLogger, + @Nullable final GlideExecutor executor) { this.requestFactory = requestFactory; this.dataLogger = dataLogger; + if (executor == null) { + this.jobPool = new JobPool(GLIDE_EXECUTOR_SUPPLIER); + } else { + this.jobPool = + new JobPool( + new Supplier() { + @Override + public Executor get() { + return executor; + } + }); + } } void startRequest(Priority priority, GlideUrl glideUrl, Listener listener) { @@ -178,6 +193,11 @@ private class Job extends Callback { private long responseStartTimeMs; private volatile boolean isCancelled; private BufferQueue.Builder builder; + private final Supplier executorSupplier; + + Job(Supplier executorSupplier) { + this.executorSupplier = executorSupplier; + } void init(GlideUrl glideUrl) { startTime = System.currentTimeMillis(); @@ -233,7 +253,7 @@ public void onReadCompleted( @Override public void onSucceeded(UrlRequest request, final UrlResponseInfo info) { - GLIDE_EXECUTOR_SUPPLIER + executorSupplier .get() .execute( new PriorityRunnable(priority) { @@ -251,7 +271,7 @@ public void run() { @Override public void onFailed( UrlRequest urlRequest, final UrlResponseInfo urlResponseInfo, final CronetException e) { - GLIDE_EXECUTOR_SUPPLIER + executorSupplier .get() .execute( new PriorityRunnable(priority) { @@ -264,7 +284,7 @@ public void run() { @Override public void onCanceled(UrlRequest urlRequest, @Nullable final UrlResponseInfo urlResponseInfo) { - GLIDE_EXECUTOR_SUPPLIER + executorSupplier .get() .execute( new PriorityRunnable(priority) { @@ -349,7 +369,7 @@ private void maybeLogResult( + (buffer.limit() / 1024) + "kb"); } else if (!isSuccess && Log.isLoggable(TAG, Log.ERROR) && !wasCancelled) { - Log.e(TAG, "Request failed", exception); + Log.e(TAG, "Request failed, url: " + glideUrl, exception); } } @@ -365,11 +385,16 @@ private void clearListeners() { private class JobPool { private static final int MAX_POOL_SIZE = 50; private final ArrayDeque pool = new ArrayDeque<>(); + private final Supplier executorSupplier; + + public JobPool(Supplier executorSupplier) { + this.executorSupplier = executorSupplier; + } public synchronized Job get(GlideUrl glideUrl) { Job job = pool.poll(); if (job == null) { - job = new Job(); + job = new Job(executorSupplier); } job.init(glideUrl); return job; diff --git a/integration/cronet/src/main/java/com/bumptech/glide/integration/cronet/ChromiumUrlLoader.java b/integration/cronet/src/main/java/com/bumptech/glide/integration/cronet/ChromiumUrlLoader.java index acae780028..8fac0f81c4 100644 --- a/integration/cronet/src/main/java/com/bumptech/glide/integration/cronet/ChromiumUrlLoader.java +++ b/integration/cronet/src/main/java/com/bumptech/glide/integration/cronet/ChromiumUrlLoader.java @@ -3,6 +3,7 @@ import androidx.annotation.Nullable; import com.bumptech.glide.load.Options; import com.bumptech.glide.load.data.DataFetcher; +import com.bumptech.glide.load.engine.executor.GlideExecutor; import com.bumptech.glide.load.model.GlideUrl; import com.bumptech.glide.load.model.ModelLoader; import com.bumptech.glide.load.model.ModelLoaderFactory; @@ -12,7 +13,10 @@ import java.nio.ByteBuffer; /** - * An {@link com.bumptech.glide.load.model.ModelLoader} for loading urls using cronet. + * A {@link com.bumptech.glide.load.model.ModelLoader} for loading urls using cronet. + * + *

You can optionally pass an executor to the constructor for handling cronet callbacks in {@link + * ChromiumRequestSerializer}. If the executor is not provided, it will be created for you. * * @param The type of data this loader will load. */ @@ -29,7 +33,17 @@ public final class ChromiumUrlLoader implements ModelLoader { CronetRequestFactory requestFactory, @Nullable DataLogger dataLogger) { this.parser = parser; - requestSerializer = new ChromiumRequestSerializer(requestFactory, dataLogger); + requestSerializer = + new ChromiumRequestSerializer(requestFactory, dataLogger, /* executor= */ null); + } + + ChromiumUrlLoader( + ByteBufferParser parser, + CronetRequestFactory requestFactory, + @Nullable DataLogger dataLogger, + @Nullable GlideExecutor executor) { + this.parser = parser; + requestSerializer = new ChromiumRequestSerializer(requestFactory, dataLogger, executor); } @Override @@ -49,15 +63,29 @@ public static final class StreamFactory private CronetRequestFactory requestFactory; @Nullable private final DataLogger dataLogger; + @Nullable private final GlideExecutor executor; public StreamFactory(CronetRequestFactory requestFactory, @Nullable DataLogger dataLogger) { this.requestFactory = requestFactory; this.dataLogger = dataLogger; + this.executor = null; + } + + /** + * @param executor See {@link ChromiumUrlLoader} for details. + */ + public StreamFactory( + CronetRequestFactory requestFactory, + @Nullable DataLogger dataLogger, + @Nullable GlideExecutor executor) { + this.requestFactory = requestFactory; + this.dataLogger = dataLogger; + this.executor = executor; } @Override public ModelLoader build(MultiModelLoaderFactory multiFactory) { - return new ChromiumUrlLoader<>(this /*parser*/, requestFactory, dataLogger); + return new ChromiumUrlLoader<>(/* parser= */ this, requestFactory, dataLogger, executor); } @Override @@ -80,15 +108,29 @@ public static final class ByteBufferFactory private CronetRequestFactory requestFactory; @Nullable private final DataLogger dataLogger; + @Nullable private final GlideExecutor executor; public ByteBufferFactory(CronetRequestFactory requestFactory, @Nullable DataLogger dataLogger) { this.requestFactory = requestFactory; this.dataLogger = dataLogger; + this.executor = null; + } + + /** + * @param executor See {@link ChromiumUrlLoader} for details. + */ + public ByteBufferFactory( + CronetRequestFactory requestFactory, + @Nullable DataLogger dataLogger, + @Nullable GlideExecutor executor) { + this.requestFactory = requestFactory; + this.dataLogger = dataLogger; + this.executor = executor; } @Override public ModelLoader build(MultiModelLoaderFactory multiFactory) { - return new ChromiumUrlLoader<>(this /*parser*/, requestFactory, dataLogger); + return new ChromiumUrlLoader<>(/* parser= */ this, requestFactory, dataLogger, executor); } @Override diff --git a/integration/cronet/src/test/AndroidManifest.xml b/integration/cronet/src/test/AndroidManifest.xml deleted file mode 100644 index 0f9d016ef7..0000000000 --- a/integration/cronet/src/test/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - diff --git a/integration/cronet/src/test/java/com/bumptech/glide/integration/cronet/ChromiumUrlFetcherTest.java b/integration/cronet/src/test/java/com/bumptech/glide/integration/cronet/ChromiumUrlFetcherTest.java index ee67f49c6b..d4321062be 100644 --- a/integration/cronet/src/test/java/com/bumptech/glide/integration/cronet/ChromiumUrlFetcherTest.java +++ b/integration/cronet/src/test/java/com/bumptech/glide/integration/cronet/ChromiumUrlFetcherTest.java @@ -7,7 +7,6 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; -import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.timeout; @@ -42,7 +41,6 @@ import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; -import org.mockito.Matchers; import org.mockito.Mock; import org.mockito.invocation.InvocationOnMock; import org.mockito.junit.MockitoJUnit; @@ -89,7 +87,9 @@ public ByteBuffer answer(InvocationOnMock invocation) throws Throwable { glideUrl = new GlideUrl("http://www.google.com"); urlRequestListenerCaptor = ArgumentCaptor.forClass(UrlRequest.Callback.class); - serializer = new ChromiumRequestSerializer(cronetRequestFactory, null /*dataLogger*/); + serializer = + new ChromiumRequestSerializer( + cronetRequestFactory, /* dataLogger= */ null, /* executor= */ null); fetcher = new ChromiumUrlFetcher<>(serializer, parser, glideUrl); builder = cronetEngine.newUrlRequestBuilder( @@ -128,9 +128,9 @@ public void testLoadData_providesHeadersFromGlideUrl() { verify(cronetRequestFactory) .newRequest( - Matchers.eq(glideUrl.toStringUrl()), + ArgumentMatchers.eq(glideUrl.toStringUrl()), anyInt(), - Matchers.eq(headers.getHeaders()), + ArgumentMatchers.eq(headers.getHeaders()), any(UrlRequest.Callback.class)); verify(request).start(); @@ -148,7 +148,7 @@ public void testLoadData_withInProgressRequest_doesNotStartNewRequest() { verify(cronetRequestFactory, times(1)) .newRequest( - Matchers.eq(glideUrl.toStringUrl()), + ArgumentMatchers.eq(glideUrl.toStringUrl()), anyInt(), ArgumentMatchers.anyMap(), any(UrlRequest.Callback.class)); @@ -279,7 +279,7 @@ public void testCancel_withStartedRequest_cancelsRequest() { @Test public void testRequestComplete_withNonNullException_callsCallbackWithException() { CronetException expected = - new CronetException("test", /*cause=*/ null) { + new CronetException("test", /* cause= */ null) { static final long serialVersionUID = 1; }; fetcher.loadData(Priority.LOW, callback); @@ -330,7 +330,7 @@ public void testRequestComplete_whenCancelledAndUnauthorized_callsCallbackWithNu urlCallback.onResponseStarted(request, info); urlCallback.onCanceled(request, info); - verify(callback, timeout(1000)).onLoadFailed(isNull(Exception.class)); + verify(callback, timeout(1000)).onLoadFailed(ArgumentMatchers.isNull()); } private void verifyAuthError() { @@ -349,7 +349,7 @@ public void testRequestComplete_with200AndCancelled_callsCallbackWithNullExcepti urlCallback.onResponseStarted(request, info); urlCallback.onCanceled(request, info); - verify(callback, timeout(1000)).onLoadFailed(isNull(Exception.class)); + verify(callback, timeout(1000)).onLoadFailed(ArgumentMatchers.isNull()); } @Test diff --git a/integration/gifencoder/build.gradle b/integration/gifencoder/build.gradle deleted file mode 100644 index fb65891480..0000000000 --- a/integration/gifencoder/build.gradle +++ /dev/null @@ -1,36 +0,0 @@ -apply plugin: 'com.android.library' - -dependencies { - implementation project(':library') - - testImplementation project(":testutil") - testImplementation "com.google.truth:truth:${TRUTH_VERSION}" - testImplementation "junit:junit:${JUNIT_VERSION}" - testImplementation "org.mockito:mockito-core:${MOCKITO_VERSION}" - testImplementation "org.robolectric:robolectric:${ROBOLECTRIC_VERSION}" - testImplementation "androidx.legacy:legacy-support-v4:${ANDROID_X_VERSION}" -} - -android { - compileSdkVersion COMPILE_SDK_VERSION as int - - sourceSets { - main { - java.srcDirs = ['src/main/java', '../../third_party/gif_encoder/src/main/java'] - } - } - - defaultConfig { - minSdkVersion MIN_SDK_VERSION as int - targetSdkVersion TARGET_SDK_VERSION as int - - versionName = VERSION_NAME as String - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 - } -} - -apply from: "${rootProject.projectDir}/scripts/upload.gradle" diff --git a/integration/gifencoder/build.gradle.kts b/integration/gifencoder/build.gradle.kts new file mode 100644 index 0000000000..04c020b304 --- /dev/null +++ b/integration/gifencoder/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "com.bumptech.glide.integration.gifencoder" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + sourceSets { + getByName("main") { + java.directories.addAll(listOf("src/main/java", "../../third_party/gif_encoder/src/main/java")) + } + } + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation(project(":library")) + + testImplementation(project(":testutil")) + testImplementation(libs.truth) + testImplementation(libs.junit) + testImplementation(libs.mockito.core) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.androidx.test.runner) +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") \ No newline at end of file diff --git a/integration/gifencoder/src/main/AndroidManifest.xml b/integration/gifencoder/src/main/AndroidManifest.xml deleted file mode 100644 index c2b8a91b37..0000000000 --- a/integration/gifencoder/src/main/AndroidManifest.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/integration/gifencoder/src/main/java/com/bumptech/glide/integration/gifencoder/ReEncodingGifResourceEncoder.java b/integration/gifencoder/src/main/java/com/bumptech/glide/integration/gifencoder/ReEncodingGifResourceEncoder.java index 04f488b08d..7a444ac159 100644 --- a/integration/gifencoder/src/main/java/com/bumptech/glide/integration/gifencoder/ReEncodingGifResourceEncoder.java +++ b/integration/gifencoder/src/main/java/com/bumptech/glide/integration/gifencoder/ReEncodingGifResourceEncoder.java @@ -39,6 +39,7 @@ public class ReEncodingGifResourceEncoder implements ResourceEncodertrue, causes the fully transformed GIF to be * written to cache. diff --git a/integration/gifencoder/src/test/java/com/bumptech/glide/integration/gifencoder/ReEncodingGifResourceEncoderTest.java b/integration/gifencoder/src/test/java/com/bumptech/glide/integration/gifencoder/ReEncodingGifResourceEncoderTest.java index 436acff79e..75e94b5def 100644 --- a/integration/gifencoder/src/test/java/com/bumptech/glide/integration/gifencoder/ReEncodingGifResourceEncoderTest.java +++ b/integration/gifencoder/src/test/java/com/bumptech/glide/integration/gifencoder/ReEncodingGifResourceEncoderTest.java @@ -15,6 +15,7 @@ import android.app.Application; import android.content.Context; import android.graphics.Bitmap; +import androidx.test.core.app.ApplicationProvider; import com.bumptech.glide.gifdecoder.GifDecoder; import com.bumptech.glide.gifdecoder.GifHeader; import com.bumptech.glide.gifdecoder.GifHeaderParser; @@ -39,12 +40,11 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; /** Tests for {@link com.bumptech.glide.integration.gifencoder.ReEncodingGifResourceEncoder}. */ @RunWith(RobolectricTestRunner.class) -@Config(manifest = Config.NONE, sdk = 18) +@Config(manifest = Config.NONE, sdk = Config.OLDEST_SDK) public class ReEncodingGifResourceEncoderTest { @Mock private Resource resource; @Mock private GifDecoder decoder; @@ -64,7 +64,7 @@ public class ReEncodingGifResourceEncoderTest { public void setUp() { MockitoAnnotations.initMocks(this); - Application context = RuntimeEnvironment.application; + Application context = ApplicationProvider.getApplicationContext(); ReEncodingGifResourceEncoder.Factory factory = mock(ReEncodingGifResourceEncoder.Factory.class); when(decoder.getNextFrame()).thenReturn(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)); diff --git a/integration/ktx/api/ktx.api b/integration/ktx/api/ktx.api new file mode 100644 index 0000000000..1d798d1cd5 --- /dev/null +++ b/integration/ktx/api/ktx.api @@ -0,0 +1,52 @@ +public abstract interface annotation class com/bumptech/glide/integration/ktx/ExperimentGlideFlows : java/lang/annotation/Annotation { +} + +public final class com/bumptech/glide/integration/ktx/FlowsKt { + public static final fun flow (Lcom/bumptech/glide/RequestBuilder;)Lkotlinx/coroutines/flow/Flow; + public static final fun flow (Lcom/bumptech/glide/RequestBuilder;I)Lkotlinx/coroutines/flow/Flow; + public static final fun flow (Lcom/bumptech/glide/RequestBuilder;II)Lkotlinx/coroutines/flow/Flow; +} + +public abstract class com/bumptech/glide/integration/ktx/GlideFlowInstant { + public abstract fun getStatus ()Lcom/bumptech/glide/integration/ktx/Status; +} + +public abstract interface annotation class com/bumptech/glide/integration/ktx/InternalGlideApi : java/lang/annotation/Annotation { +} + +public final class com/bumptech/glide/integration/ktx/Placeholder : com/bumptech/glide/integration/ktx/GlideFlowInstant { + public fun (Lcom/bumptech/glide/integration/ktx/Status;Landroid/graphics/drawable/Drawable;)V + public final fun component1 ()Lcom/bumptech/glide/integration/ktx/Status; + public final fun component2 ()Landroid/graphics/drawable/Drawable; + public final fun copy (Lcom/bumptech/glide/integration/ktx/Status;Landroid/graphics/drawable/Drawable;)Lcom/bumptech/glide/integration/ktx/Placeholder; + public static synthetic fun copy$default (Lcom/bumptech/glide/integration/ktx/Placeholder;Lcom/bumptech/glide/integration/ktx/Status;Landroid/graphics/drawable/Drawable;ILjava/lang/Object;)Lcom/bumptech/glide/integration/ktx/Placeholder; + public fun equals (Ljava/lang/Object;)Z + public final fun getPlaceholder ()Landroid/graphics/drawable/Drawable; + public fun getStatus ()Lcom/bumptech/glide/integration/ktx/Status; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/bumptech/glide/integration/ktx/Resource : com/bumptech/glide/integration/ktx/GlideFlowInstant { + public fun (Lcom/bumptech/glide/integration/ktx/Status;Ljava/lang/Object;)V + public final fun component1 ()Lcom/bumptech/glide/integration/ktx/Status; + public final fun component2 ()Ljava/lang/Object; + public final fun copy (Lcom/bumptech/glide/integration/ktx/Status;Ljava/lang/Object;)Lcom/bumptech/glide/integration/ktx/Resource; + public static synthetic fun copy$default (Lcom/bumptech/glide/integration/ktx/Resource;Lcom/bumptech/glide/integration/ktx/Status;Ljava/lang/Object;ILjava/lang/Object;)Lcom/bumptech/glide/integration/ktx/Resource; + public fun equals (Ljava/lang/Object;)Z + public final fun getResource ()Ljava/lang/Object; + public fun getStatus ()Lcom/bumptech/glide/integration/ktx/Status; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/bumptech/glide/integration/ktx/Status : java/lang/Enum { + public static final field CLEARED Lcom/bumptech/glide/integration/ktx/Status; + public static final field FAILED Lcom/bumptech/glide/integration/ktx/Status; + public static final field RUNNING Lcom/bumptech/glide/integration/ktx/Status; + public static final field SUCCEEDED Lcom/bumptech/glide/integration/ktx/Status; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/bumptech/glide/integration/ktx/Status; + public static fun values ()[Lcom/bumptech/glide/integration/ktx/Status; +} + diff --git a/integration/ktx/build.gradle.kts b/integration/ktx/build.gradle.kts new file mode 100644 index 0000000000..d8e240aa31 --- /dev/null +++ b/integration/ktx/build.gradle.kts @@ -0,0 +1,53 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("com.android.library") +} + +android { + namespace = "com.bumptech.glide.integration.ktx" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { getByName("release") { isMinifyEnabled = false } } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + +} + +// Enable strict mode, but exclude tests. +tasks.withType(KotlinCompile::class.java).configureEach { + if (!name.contains("Test")) { + compilerOptions.freeCompilerArgs.add("-Xexplicit-api=strict") + } +} + +dependencies { + api(project(":library")) + implementation(libs.androidx.core.ktx) + implementation(libs.coroutines.core) + + testImplementation(libs.androidx.espresso) + testImplementation(libs.androidx.espresso.idling) + testImplementation(libs.androidx.test.ktx) + testImplementation(libs.kotlin.junit) + testImplementation(libs.androidx.test.ktx.junit) + testImplementation(libs.androidx.junit) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.runner) + testImplementation(libs.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.truth) + + androidTestImplementation(libs.androidx.junit) +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") diff --git a/integration/ktx/gradle.properties b/integration/ktx/gradle.properties new file mode 100644 index 0000000000..cb986b3936 --- /dev/null +++ b/integration/ktx/gradle.properties @@ -0,0 +1,9 @@ +POM_NAME=Glide Kotlin Extensions +POM_ARTIFACT_ID=ktx +POM_PACKAGING=aar +POM_DESCRIPTION=An integration library to improve Kotlin interop with Glide + +VERSION_MAJOR=1 +VERSION_MINOR=0 +VERSION_PATCH=0 +VERSION_NAME=1.0.0-beta08 \ No newline at end of file diff --git a/integration/ktx/src/main/java/com/bumptech/glide/GlideIntegration.kt b/integration/ktx/src/main/java/com/bumptech/glide/GlideIntegration.kt new file mode 100644 index 0000000000..c109172009 --- /dev/null +++ b/integration/ktx/src/main/java/com/bumptech/glide/GlideIntegration.kt @@ -0,0 +1,19 @@ +/** + * Functions that give us access to some of Glide's non-public internals to make the flows API a bit + * better. + */ +package com.bumptech.glide + +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target + +internal fun RequestBuilder<*>.requestManager() = this.requestManager + +internal fun RequestBuilder.intoDirect( + targetAndRequestListener: TargetAndRequestListenerT +) + where + TargetAndRequestListenerT : Target, + TargetAndRequestListenerT : RequestListener { + this.into(targetAndRequestListener, targetAndRequestListener) { it.run() } +} diff --git a/integration/ktx/src/main/java/com/bumptech/glide/integration/ktx/Flows.kt b/integration/ktx/src/main/java/com/bumptech/glide/integration/ktx/Flows.kt new file mode 100644 index 0000000000..86136aefc0 --- /dev/null +++ b/integration/ktx/src/main/java/com/bumptech/glide/integration/ktx/Flows.kt @@ -0,0 +1,403 @@ +package com.bumptech.glide.integration.ktx + +import android.graphics.drawable.Drawable +import androidx.annotation.GuardedBy +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.intoDirect +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.Request +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.SizeReadyCallback +import com.bumptech.glide.request.target.Target +import com.bumptech.glide.request.transition.Transition +import com.bumptech.glide.requestManager +import com.bumptech.glide.util.Util +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch + +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = + "Glide's flow integration is very experimental and subject to breaking API or behavior changes", +) +@Retention(AnnotationRetention.BINARY) +@kotlin.annotation.Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +public annotation class ExperimentGlideFlows + +/** + * The current status of a flow + * + * There is no well established graph that defines the valid Status transitions. Depending on + * various factors like the parameters of the request, whether or not the resource is in the memory + * cache, or even various calls to Glide's APIs, these may be emitted in different orders. As an + * example, [RUNNING] is skipped if a request can be immediately completed from the memory cache. + * + * See [flow] for more details. + */ +@ExperimentGlideFlows +public enum class Status { + /** The load is not started or has been cleared. */ + CLEARED, + /** At least the primary load is still in progress. */ + RUNNING, + /** + * The primary load or the error load ([RequestBuilder.error]) associated with the primary have + * finished successfully. + */ + SUCCEEDED, + /** The primary load has failed. One or more thumbnails may have succeeded. */ + FAILED, +} + +/** + * Identical to [flow] with [Target.SIZE_ORIGINAL] as the dimensions + * + * This isn't generally a good idea, [Target.SIZE_ORIGINAL] is often much larger than you need. + * Using it unnecessarily will waste memory and cache space. It will also slow down future loads + * from the disk cache. + * + * Use this method only if you you expect the request and all of the subrequests ( + * [RequestBuilder.override] and [RequestBuilder.error] to have specific sizes set). Validation is + * only performed on the top level request because we cannot reliably verify all possible + * subrequests. + */ +@ExperimentGlideFlows +public fun RequestBuilder.flow(): Flow> { + require(isValidOverride) { + "At least your primary request is missing override dimensions. If you want to use" + + " Target.SIZE_ORIGINAL, do so explicitly" + } + return flow(Target.SIZE_ORIGINAL) +} + +/** Identical to `flow(dimension, dimension)` */ +@ExperimentGlideFlows +public fun RequestBuilder.flow( + dimension: Int +): Flow> = flow(dimension, dimension) + +/** + * Identical to [flow] with dimensions, except that the size is resolved asynchronously using + * [waitForSize]. + * + * If an override size has been set using [RequestBuilder.override], that size will be used instead + * and [waitForSize] may never be called. + * + * [Placeholder] values may be emitted prior to [waitForSize] returning. Similarly if + * [RequestBuilder.thumbnail] requests are present and have overridden sizes, [Resource] values for + * those thumbnails may also be emitted. [waitForSize] will only be used for requests where no + * [RequestBuilder.override] size is available. + * + * If [waitForSize] does not return, this flow may never return values other than placeholders. + * + * This function is internal only, intended primarily for Compose. The Target API provides similar + * functionality for traditional Views. We could consider expanding the visibility if there are use + * cases for asynchronous size resolution outside of Glide's Compose integration. + */ +@InternalGlideApi +@ExperimentGlideFlows +public fun RequestBuilder.flow( + waitForSize: suspend () -> Size +): Flow> = flow(AsyncGlideSize(waitForSize)) + +/** + * Convert a load in Glide into a flow that emits placeholders and resources in the order they'd be + * seen by a [Target]. + * + * Just like a [Target] there is no well defined end to a Glide request. Barring cancellation, the + * flow should eventually reach [Status.SUCCEEDED] or [Status.FAILED] at least once. However + * connectivity changes, calls to [com.bumptech.glide.RequestManager.pauseAllRequests] or + * [com.bumptech.glide.RequestManager.resumeRequests], or the lifecycle associated with this request + * may cause the request to be started multiple times. As long as the flow is active, callers will + * receive emissions from every run. + * + * This flow will clear the associated Glide request when it's cancelled. This means that callers + * must keep the flow active while any resource emitted by the flow is still in use. For UI + * contexts, collecting the flow in the appropriate fragment or view model coroutine context is + * sufficient as long as you avoid truncating methods like [kotlinx.coroutines.flow.take], + * [kotlinx.coroutines.flow.takeWhile], etc. If you do use these methods, you must be sure that + * you're no longer using or displaying the associated resource once the flow is no longer active + * (ie [kotlinx.coroutines.flow.collect] finishes). One way to do this would be to mimic the UI by + * creating and keeping active a coroutine context that collects from the flow while the resource is + * in use. If this restriction is limiting for you, please file an issue on Github so we can think + * of alternative options. + * + * If there have been any previous calls to this [RequestBuilder]'s + * [com.bumptech.glide.request.RequestOptions.override] method, the size specified in that method + * will be used instead of the size provided here. This includes calls where override sizes may have + * been copied from other option sets via [RequestBuilder.apply]. + */ +@ExperimentGlideFlows +@OptIn(InternalGlideApi::class) +public fun RequestBuilder.flow( + width: Int, + height: Int, +): Flow> { + require(Util.isValidDimensions(width, height)) + return flow(Size(width = width, height = height)) +} + +// We're not asserting on size here because it might come from RequestBuilder.override. Assertions +// for provided sizes belong in those methods, assertions for overrides belong in the override +// method. +@InternalGlideApi +@ExperimentGlideFlows +private fun RequestBuilder.flow( + size: Size +): Flow> = flowResolvable(ImmediateGlideSize(size)) + +@OptIn(ExperimentGlideFlows::class) +@InternalGlideApi +public fun RequestBuilder.flowResolvable( + size: ResolvableGlideSize +): Flow> = flow(size) + +/** + * A [Status] and value pair, where the value is either a [Placeholder] or a [Resource] depending on + * how far the Glide load has progressed and/or how successful it's been. + */ +@ExperimentGlideFlows +public sealed class GlideFlowInstant { + public abstract val status: Status +} + +/** + * Wraps a [Status] and a placeholder [Drawable] (from [RequestBuilder.placeholder], + * [RequestBuilder.fallback], [RequestBuilder.error] etc). + */ +@ExperimentGlideFlows +public data class Placeholder( + public override val status: Status, + public val placeholder: Drawable?, +) : GlideFlowInstant() { + init { + require( + when (status) { + Status.SUCCEEDED -> false + Status.CLEARED -> true + // Placeholder will be present prior to the first thumbnail succeeding + Status.RUNNING -> true + Status.FAILED -> true + } + ) + } +} + +/** + * Wraps a [Status] and a resource loaded from the primary request, a [RequestBuilder.thumbnail] + * request, or a [RequestBuilder.error] request. + * + * **Status.FAILED** is a perfectly valid status with this class. If the primary request fails, but + * at least one thumbnail succeeds, the flow will emit `Resource(FAILED, resource)` to indicate both + * that we have some value but also that the primary request has failed. + */ +@ExperimentGlideFlows +public data class Resource( + public override val status: Status, + public val resource: ResourceT, +) : GlideFlowInstant() { + init { + require( + when (status) { + Status.SUCCEEDED -> true + // A load with thumbnail(s) where the thumbnail(s) have finished but not the main + // request + Status.RUNNING -> true + // The primary request of the load failed, but at least one thumbnail was + // successful. + Status.FAILED -> true + // Once the load is cleared, it can only show a placeholder + Status.CLEARED -> false + } + ) + } +} + +@InternalGlideApi +@ExperimentGlideFlows +private fun RequestBuilder.flow( + size: ResolvableGlideSize +): Flow> { + val requestBuilder = this + val requestManager = requestBuilder.requestManager() + return callbackFlow { + val target = FlowTarget(this, size) + requestBuilder.intoDirect(target) + awaitClose { requestManager.clear(target) } + } +} + +/** + * Observes a glide request using [Target] and [RequestListener] and tries to emit something + * resembling a coherent set of placeholders and resources for it. + * + * Threading in this class is a bit complicated. As a general rule, the callback methods are ordered + * by callers. So we have to handle being called from multiple threads, but we don't need to try to + * handle callbacks being called in parallel. + * + * The primary area of concern around thread is that [resolvedSize] and [sizeReadyCallbacks] must be + * updated atomically, but can be modified on different threads. + * + * [currentRequest] would normally be a concern because [Target]s can be cancelled on threads other + * than where they were started. However in our case, [currentRequest] is set once when our request + * is started (by us) and is only cancelled when the request finishes. So we just have to avoid NPEs + * and make sure the state is reasonably up to date. + * + * [lastResource] is an unfortunate hack that tries to make sure that we emit [Status.FAILED] if a + * thumbnail request succeeds, but then the primary request fails. In that case, we'd normally + * already have emitted [Resource] with [Status.RUNNING] and the thumbnail value and then we'd emit + * nothing else. That's not very satisfying for callers who expect some resolution. So instead we + * track the last resource produced by thumbnails and emit that along with [Status.FAILED] when we + * see that the primary request has failed. As a result we're not concerned with ordering with + * regards to [lastResource], but it is possible the callbacks will be called on different threads, + * so the value may be updated from different threads even if it's not concurrent. + */ +@ExperimentGlideFlows +@InternalGlideApi +private class FlowTarget( + private val scope: ProducerScope>, + private val size: ResolvableGlideSize, +) : Target, RequestListener { + @Volatile private var resolvedSize: Size? = null + @Volatile private var currentRequest: Request? = null + @Volatile private var lastResource: ResourceT? = null + + @GuardedBy("this") private val sizeReadyCallbacks = mutableListOf() + + init { + when (size) { + // If we have a size, skip the coroutine, we can continue immediately. + is ImmediateGlideSize -> resolvedSize = size.size + // Otherwise, we do not want to block the flow while waiting on a size because one or + // more + // requests in the chain may have a fixed size, even if the primary request does not. + // Starting the Glide request right away allows any subrequest that has a fixed size to + // begin immediately, shaving off some small amount of time. + is AsyncGlideSize -> + scope.launch { + val localResolvedSize = size.asyncSize() + val callbacksToNotify: List + synchronized(this) { + resolvedSize = localResolvedSize + callbacksToNotify = ArrayList(sizeReadyCallbacks) + sizeReadyCallbacks.clear() + } + callbacksToNotify.forEach { + it.onSizeReady(localResolvedSize.width, localResolvedSize.height) + } + } + } + } + + override fun onStart() {} + + override fun onStop() {} + + override fun onDestroy() {} + + override fun onLoadStarted(placeholder: Drawable?) { + lastResource = null + scope.trySend(Placeholder(Status.RUNNING, placeholder)) + } + + override fun onLoadFailed(errorDrawable: Drawable?) { + scope.trySend(Placeholder(Status.FAILED, errorDrawable)) + } + + override fun onResourceReady(resource: ResourceT, transition: Transition?) { + lastResource = resource + scope.trySend( + Resource( + // currentRequest is the entire request state, so we can use it to figure out if + // this + // resource is from a thumbnail request (isComplete is false) or the primary + // request. + if (currentRequest?.isComplete == true) Status.SUCCEEDED else Status.RUNNING, + resource, + ) + ) + } + + override fun onLoadCleared(placeholder: Drawable?) { + lastResource = null + scope.trySend(Placeholder(Status.CLEARED, placeholder)) + } + + override fun getSize(cb: SizeReadyCallback) { + val localResolvedSize = resolvedSize + if (localResolvedSize != null) { + cb.onSizeReady(localResolvedSize.width, localResolvedSize.height) + return + } + + synchronized(this@FlowTarget) { + val lockedResolvedSize = resolvedSize + if (lockedResolvedSize != null) { + cb.onSizeReady(lockedResolvedSize.width, lockedResolvedSize.height) + } else { + sizeReadyCallbacks.add(cb) + } + } + } + + override fun removeCallback(cb: SizeReadyCallback) { + synchronized(this) { sizeReadyCallbacks.remove(cb) } + } + + override fun setRequest(request: Request?) { + currentRequest = request + } + + override fun getRequest(): Request? { + return currentRequest + } + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean, + ): Boolean { + val localLastResource = lastResource + val localRequest = currentRequest + if ( + localLastResource != null && + localRequest?.isComplete == false && + !localRequest.isRunning + ) { + scope.channel.trySend(Resource(Status.FAILED, localLastResource)) + } + return false + } + + override fun onResourceReady( + resource: ResourceT, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean, + ): Boolean { + return false + } +} + +@InternalGlideApi +public data class Size(val width: Int, val height: Int) { + init { + require(width.isValidGlideDimension()) + require(height.isValidGlideDimension()) + } +} + +@InternalGlideApi public sealed class ResolvableGlideSize + +@InternalGlideApi public data class ImmediateGlideSize(val size: Size) : ResolvableGlideSize() + +@InternalGlideApi +public data class AsyncGlideSize(val asyncSize: suspend () -> Size) : ResolvableGlideSize() + +@InternalGlideApi public fun Int.isValidGlideDimension(): Boolean = Util.isValidDimension(this) diff --git a/integration/ktx/src/main/java/com/bumptech/glide/integration/ktx/InternalGlideApi.kt b/integration/ktx/src/main/java/com/bumptech/glide/integration/ktx/InternalGlideApi.kt new file mode 100644 index 0000000000..508d428bde --- /dev/null +++ b/integration/ktx/src/main/java/com/bumptech/glide/integration/ktx/InternalGlideApi.kt @@ -0,0 +1,11 @@ +package com.bumptech.glide.integration.ktx + +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = + "An internal only API not intended for public use, may change, break or be removed" + + " at any time without warning.", +) +@Retention(AnnotationRetention.BINARY) +@kotlin.annotation.Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +public annotation class InternalGlideApi diff --git a/integration/ktx/src/test/java/com/bumptech/glide/integration/ktx/FlowsTest.kt b/integration/ktx/src/test/java/com/bumptech/glide/integration/ktx/FlowsTest.kt new file mode 100644 index 0000000000..f14578f86e --- /dev/null +++ b/integration/ktx/src/test/java/com/bumptech/glide/integration/ktx/FlowsTest.kt @@ -0,0 +1,769 @@ +@file:OptIn(InternalGlideApi::class, ExperimentGlideFlows::class, ExperimentalCoroutinesApi::class) + +package com.bumptech.glide.integration.ktx + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.Espresso.onIdle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bumptech.glide.Glide +import com.bumptech.glide.GlideBuilder +import com.bumptech.glide.RequestManager +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.Key +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.load.engine.cache.MemoryCache +import com.bumptech.glide.load.engine.executor.GlideExecutor +import com.bumptech.glide.load.engine.executor.GlideIdlingResources +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.request.RequestListener +import com.bumptech.glide.request.target.Target +import com.google.common.truth.Correspondence +import com.google.common.truth.IterableSubject +import com.google.common.truth.Truth.assertThat +import java.io.File +import java.lang.RuntimeException +import java.util.concurrent.atomic.AtomicReference +import kotlin.reflect.KClass +import kotlin.test.assertFailsWith +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith + +// newFile throws IOException, which triggers this warning even though there's no reasonable +// alternative :/. +@Suppress("BlockingMethodInNonBlockingContext", "RedundantSuppression") +@RunWith(AndroidJUnit4::class) +class FlowsTest { + private val context = ApplicationProvider.getApplicationContext() + @get:Rule val temporaryFolder = TemporaryFolder() + + @Before + fun setUp() { + GlideIdlingResources.initGlide() + } + + @After + fun tearDown() { + Glide.tearDown() + } + + @Test + fun flow_withPlaceholderDrawable_emitsPlaceholderDrawableFirst() = runTest { + val placeholderDrawable = ColorDrawable(Color.RED) + val first = + Glide.with(context) + .load(temporaryFolder.newFile()) + .placeholder(placeholderDrawable) + .flow(100) + .first() + + assertThat(first).isEqualTo(Placeholder(Status.RUNNING, placeholderDrawable)) + } + + @Test + fun flow_withNoPlaceholderDrawable_emitsNullPlaceholderFirst() = runTest { + val first = Glide.with(context).load(temporaryFolder.newFile()).flow(100).first() + + assertThat(first).isEqualTo(Placeholder(Status.RUNNING, placeholder = null)) + } + + @Test + fun flow_failingNonNullModel_emitsRunningThenFailed() = runTest { + val missingResourceId = 123 + val results = Glide.with(context).load(missingResourceId).flow(100).firstLoad().toList() + + assertThat(results) + .containsExactly( + Placeholder(Status.RUNNING, placeholder = null), + Placeholder(Status.FAILED, placeholder = null), + ) + .inOrder() + } + + @Test + fun flow_failingNonNullModel_whenRestartedAfterFailure_emitsSecondLoad() = runTest { + val requestManager = Glide.with(context) + val missingResourceId = 123 + + val flow = + requestManager + .load(missingResourceId) + .listener(onFailure(atMostOnce { restartAllRequestsOnNewThread(requestManager) })) + .flow(100) + + assertThat(flow.take(4).toList()) + .comparingStatus() + .containsExactly(Status.RUNNING, Status.FAILED, Status.RUNNING, Status.FAILED) + } + + @Test + fun flow_successfulNonNullModel_emitsRunningThenSuccess() = runTest { + val results = Glide.with(context).load(newImageFile()).flow(100).firstLoad().toList() + + assertThat(results) + .compareStatusAndType() + .containsExactly(placeholder(Status.RUNNING), resource(Status.SUCCEEDED)) + .inOrder() + } + + @Test + fun flow_withNullModel_andFallbackDrawable_emitsFailureWithFallbackDrawable() = runTest { + val fallbackDrawable = ColorDrawable(Color.BLUE) + val first = + Glide.with(context).load(null as Uri?).fallback(fallbackDrawable).flow(100).first() + assertThat(first).isEqualTo(Placeholder(Status.FAILED, fallbackDrawable)) + } + + @Test + fun flow_successfulNonNullModel_whenRestartedAfterSuccess_emitsSecondLoad() = runTest { + val requestManager = Glide.with(context) + + val flow = + requestManager + .load(newImageFile()) + .listener(onSuccess(atMostOnce { restartAllRequestsOnNewThread(requestManager) })) + .flow(100) + + assertThat(flow.take(4).toList()) + .comparingStatus() + .containsExactly( + Status.RUNNING, + Status.SUCCEEDED, + Status.CLEARED, // See the TODO in RequestTracker#pauseAllRequests + Status + .SUCCEEDED, // The request completes from in memory, so it never goes to RUNNING + ) + } + + @Test + fun flow_successfulNonNullModel_oneSuccessfulThumbnail_emitsThumbnailAndMainResources() = + runTest { + makeGlideSingleThreadedToOrderThumbnailRequests() + + val output = + Glide.with(context) + .load(newImageFile()) + .thumbnail(Glide.with(context).load(newImageFile())) + .flow(100) + .firstLoad() + .toList() + assertThat(output) + .compareStatusAndType() + .containsExactly( + placeholder(Status.RUNNING), + resource(Status.RUNNING), + resource(Status.SUCCEEDED), + ) + } + + @Test + fun flow_successfulNonNullModel_oneFailingThumbnail_emitMainResourceOnly() = runTest { + makeGlideSingleThreadedToOrderThumbnailRequests() + + val missingResourceId = 123 + val output = + Glide.with(context) + .load(newImageFile()) + .thumbnail(Glide.with(context).load(missingResourceId)) + .flow(100) + .firstLoad() + .toList() + assertThat(output) + .compareStatusAndType() + .containsExactly(placeholder(Status.RUNNING), resource(Status.SUCCEEDED)) + } + + @Test + fun flow_failingNonNullModel_successfulThumbnail_emitsThumbnailWithFailedStatus() = runTest { + makeGlideSingleThreadedToOrderThumbnailRequests() + + val missingResourceId = 123 + val output = + Glide.with(context) + .load(missingResourceId) + .thumbnail(Glide.with(context).load(newImageFile())) + .flow(100) + .firstLoad() + .toList() + assertThat(output) + .compareStatusAndType() + .containsExactly( + placeholder(Status.RUNNING), + resource(Status.RUNNING), + resource(Status.FAILED), + ) + } + + @Test + fun flow_failingNonNullModel_failingNonNullThumbnail_emitsRunningThenFailed() = runTest { + makeGlideSingleThreadedToOrderThumbnailRequests() + + val missingResourceId = 123 + val output = + Glide.with(context) + .load(missingResourceId) + .thumbnail(Glide.with(context).load(missingResourceId)) + .flow(100) + .firstLoad() + .toList() + + assertThat(output) + .compareStatusAndType() + .containsExactly(placeholder(Status.RUNNING), placeholder(Status.FAILED)) + } + + @Test + fun flow_failingNonNullModel_succeedingNonNullError_emitsRunningThenSuccess() = runTest { + makeGlideSingleThreadedToOrderThumbnailRequests() + + val missingResourceId = 123 + val output = + Glide.with(context) + .load(missingResourceId) + .error(Glide.with(context).load(newImageFile())) + .flow(100) + .firstLoad() + .toList() + + assertThat(output) + .compareStatusAndType() + .containsExactly( + placeholder(Status.RUNNING), + // TODO(judds): This is probably another case where resource(Status.FAILURE) is more + // appropriate. TO do so, we'd need to avoid passing TargetListener in + // RequestBuilder into + // thumbnails (and probably error request builders). That's a larger change + resource(Status.SUCCEEDED), + ) + } + + @Test + fun flow_failingNonNullModel_failingNonNullError_emitsRunningThenFailure() = runTest { + makeGlideSingleThreadedToOrderThumbnailRequests() + + val missingResourceId = 123 + val output = + Glide.with(context) + .load(missingResourceId) + .error(Glide.with(context).load(missingResourceId)) + .flow(100) + .firstLoad() + .toList() + + assertThat(output) + .compareStatusAndType() + .containsExactly(placeholder(Status.RUNNING), placeholder(Status.FAILED)) + } + + @Test + fun flow_failingNonNullModel_failingNonNullError_succeedingErrorThumbnail_emitsRunningThenRunningWithResourceThenFailureWithResource() = + runTest { + makeGlideSingleThreadedToOrderThumbnailRequests() + + val missingResourceId = 123 + val output = + Glide.with(context) + .load(missingResourceId) + .error( + Glide.with(context) + .load(missingResourceId) + .thumbnail(Glide.with(context).load(newImageFile())) + ) + .flow(100) + .firstLoad() + .toList() + + assertThat(output) + .compareStatusAndType() + .containsExactly( + placeholder(Status.RUNNING), + resource(Status.RUNNING), + resource(Status.FAILED), + ) + } + + @Test + fun flow_onClose_clearsTarget() = runTest { + val inCache = AtomicReference?>() + GlideIdlingResources.initGlide( + GlideBuilder() + .setMemoryCache( + object : MemoryCache { + override fun getCurrentSize(): Long = 0 + + override fun getMaxSize(): Long = 0 + + override fun setSizeMultiplier(multiplier: Float) {} + + override fun remove(key: Key): com.bumptech.glide.load.engine.Resource<*>? { + return null + } + + override fun setResourceRemovedListener( + listener: MemoryCache.ResourceRemovedListener + ) {} + + override fun clearMemory() {} + + override fun trimMemory(level: Int) {} + + override fun put( + key: Key, + resource: com.bumptech.glide.load.engine.Resource<*>?, + ): com.bumptech.glide.load.engine.Resource<*>? { + inCache.set(resource) + return null + } + } + ) + ) + val data = Glide.with(context).load(newImageFile()).flow(100, 100).firstLoad().toList() + assertThat(data).isNotEmpty() + // Glide's executor (in EngineJob's notify loop) and the coroutine race to close the + // resource. + // If Glide's executor wins, then the coroutine will be able to put the resource in the + // cache, + // but if not, the item won't be cached until after the coroutine starts back up. + onIdle() + assertThat(inCache.get()).isNotNull() + } + + @Test + fun flow_withOverrideSize_andProvidedSize_prefersOverrideSize() = runTest { + val requestedSizeReference = registerSizeCapturingFakeModelLoader() + + Glide.with(context).load(FakeModel()).override(50, 60).flow(200, 100).firstLoad().toList() + + assertThat(requestedSizeReference.get()).isEqualTo(Size(50, 60)) + } + + @Test + fun flow_withOnlyProvidedSize_usesProvidedSize() = runTest { + val requestedSizeReference = registerSizeCapturingFakeModelLoader() + + Glide.with(context).load(FakeModel()).flow(100, 200).firstLoad().toList() + + assertThat(requestedSizeReference.get()).isEqualTo(Size(100, 200)) + } + + @Test + fun flow_withOnlySingleDimension_usesProvidedSize() = runTest { + val requestedSizeReference = registerSizeCapturingFakeModelLoader() + + Glide.with(context).load(FakeModel()).flow(150).firstLoad().toList() + + assertThat(requestedSizeReference.get()).isEqualTo(Size(150, 150)) + } + + @Test + fun flow_withSizeOriginal_usesSizeOriginal() = runTest { + val requestedSizeReference = registerSizeCapturingFakeModelLoader() + + Glide.with(context) + .load(FakeModel()) + .flow(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + .firstLoad() + .toList() + + assertThat(requestedSizeReference.get()) + .isEqualTo(Size(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)) + } + + @Test + fun flow_withSizeOriginalOverride_concreteProvidedSize_usesSizeOriginal() = runTest { + val requestedSizeReference = registerSizeCapturingFakeModelLoader() + + Glide.with(context) + .load(FakeModel()) + .override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + .flow(200, 300) + .firstLoad() + .toList() + + assertThat(requestedSizeReference.get()) + .isEqualTo(Size(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)) + } + + @Test + fun flow_withConcreteOverride_sizeOriginalProvidedSize_usesConcreteSize() = runTest { + val requestedSizeReference = registerSizeCapturingFakeModelLoader() + + Glide.with(context) + .load(FakeModel()) + .override(200, 300) + .flow(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + .firstLoad() + .toList() + + assertThat(requestedSizeReference.get()).isEqualTo(Size(200, 300)) + } + + @Test + fun flow_withThumbnailWithOverrideSize_usesOverrideSizeForThumbnail() = runTest { + makeGlideSingleThreadedToOrderThumbnailRequests() + + val requestedSizeReference = registerSizeCapturingFakeModelLoader() + + Glide.with(context) + .load(newImageFile()) + .thumbnail(Glide.with(context).load(FakeModel()).override(100, 200)) + .flow(Target.SIZE_ORIGINAL) + .firstLoad() + .toList() + + assertThat(requestedSizeReference.get()).isEqualTo(Size(100, 200)) + } + + @Test + fun flow_withThumbnailWithoutOverrideSize_usesProvidedSizeForThumbnail() = runTest { + makeGlideSingleThreadedToOrderThumbnailRequests() + val requestedSizeReference = registerSizeCapturingFakeModelLoader() + + Glide.with(context) + .load(newImageFile()) + .thumbnail(Glide.with(context).load(FakeModel())) + .flow(300, 400) + .firstLoad() + .toList() + + assertThat(requestedSizeReference.get()).isEqualTo(Size(300, 400)) + } + + @Test + fun flow_withInvalidProvidedWith_throws() = runTest { + val missingResourceId = 123 + val requestBuilder = Glide.with(context).load(missingResourceId) + + assertFailsWith { requestBuilder.flow(-100, 100) } + } + + @Test + fun flow_withInvalidProvidedHeight_throws() { + val missingResourceId = 123 + val requestBuilder = Glide.with(context).load(missingResourceId) + + assertFailsWith { requestBuilder.flow(100, -100) } + } + + @Test + fun flow_withAsyncSize_immediatelyEmitsPlaceholder() = runTest { + val placeholder = ColorDrawable(Color.GREEN) + + val missingResourceId = 123 + val result = + Glide.with(context) + .load(missingResourceId) + .placeholder(placeholder) + .flow(delayForever) + .first() + + assertThat(result).isEqualTo(Placeholder(Status.RUNNING, placeholder)) + } + + @Test + fun flow_withAsyncSizeThatNeverCompletes_andOverrideSize_finishesSuccessfully() = runTest { + val result = + Glide.with(context) + .load(newImageFile()) + .override(100, 100) + .flow(delayForever) + .firstLoad() + .toList() + + assertThat(result) + .comparingStatus() + .containsExactly(Status.RUNNING, Status.SUCCEEDED) + .inOrder() + } + + @Test + fun flow_withAsyncSize_andOverrideSize_usesOverrideSize() = runTest { + val requestedSizeReference = registerSizeCapturingFakeModelLoader() + + Glide.with(context) + .load(FakeModel()) + .override(200, 100) + .flow { Size(1, 2) } + .firstLoad() + .toList() + + assertThat(requestedSizeReference.get()).isEqualTo(Size(200, 100)) + } + + @Test + fun flow_withAsyncSize_thumbnailWithConcreteSize_startsThumbnailWithoutWaitingForSize() = + runTest { + val result = + Glide.with(context) + .load(newImageFile()) + .thumbnail(Glide.with(context).load(newImageFile()).override(25, 50)) + .flow(delayForever) + .take(2) + .toList() + + assertThat(result) + .compareStatusAndType() + .containsExactly(placeholder(Status.RUNNING), resource(Status.RUNNING)) + .inOrder() + } + + @Test + fun flow_withAsyncSize_concreteSizeForThumbnail_startsMainRequestWhenAsyncSizeIsAvailable() = + runTest { + val waitForThumbnailToFinishChannel = Channel() + val waitForThumbnailToFinishSize: suspend () -> Size = { + waitForThumbnailToFinishChannel.receive() + Size(100, 200) + } + + val result = + Glide.with(context) + .load(newImageFile()) + .thumbnail( + Glide.with(context) + .load(newImageFile()) + .override(75, 50) + .listener( + onSuccess { launch { waitForThumbnailToFinishChannel.send(true) } } + ) + ) + .flow(waitForThumbnailToFinishSize) + .firstLoad() + .toList() + + assertThat(result) + .compareStatusAndType() + .containsExactly( + placeholder(Status.RUNNING), + resource(Status.RUNNING), + resource(Status.SUCCEEDED), + ) + } + + // TODO(judds): Consider adding a test for invalid async sizes. It doesn't seem like Glide + // asserts on this in the existing framework, so it's probably not super important to do for + // flows, but it might be nice. + + @Test + fun flow_withNoProvidedSize_overrideSizePresent_usesOverrideSize() = runTest { + val requestedSizeReference = registerSizeCapturingFakeModelLoader() + + Glide.with(context).load(FakeModel()).override(4, 5).flow().firstLoad().toList() + + assertThat(requestedSizeReference.get()).isEqualTo(Size(4, 5)) + } + + @Test + fun flow_withNoProvidedSize_overrideSizeMissing_throws() = runTest { + val requestBuilder = Glide.with(context).load(FakeModel()) + + assertFailsWith { requestBuilder.flow() } + } + + private val delayForever: suspend () -> Size = { + delay(kotlin.time.Duration.INFINITE) + throw RuntimeException() + } + + private fun registerSizeCapturingFakeModelLoader(): AtomicReference { + val result = AtomicReference() + Glide.get(context) + .registry + .append( + FakeModel::class.java, + File::class.java, + SizeObservingFakeModelLoader.Factory(newImageFile(), result), + ) + return result + } + + // Avoid race conditions where the main request finishes first by making sure they execute + // sequentially using a single threaded executor. + private fun makeGlideSingleThreadedToOrderThumbnailRequests() { + Glide.init( + context, + GlideBuilder() + .setSourceExecutor(GlideExecutor.newSourceBuilder().setThreadCount(1).build()), + ) + } + + // Robolectric will produce a Bitmap from any File, but this is relatively easy and will work on + // emulators as well as robolectric. + private fun newImageFile(): File { + val file = temporaryFolder.newFile() + val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.GREEN) + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 75, it) } + return file + } + + class FakeModel + + class SizeObservingFakeModelLoader( + private val fileLoader: ModelLoader, + private val fakeResult: File, + private val sizeReference: AtomicReference, + ) : ModelLoader { + + override fun buildLoadData( + model: FakeModel, + width: Int, + height: Int, + options: Options, + ): ModelLoader.LoadData? { + sizeReference.set(Size(width, height)) + return fileLoader.buildLoadData(fakeResult, width, height, options) + } + + override fun handles(model: FakeModel): Boolean = true + + class Factory( + private val fakeResult: File, + private val sizeReference: AtomicReference, + ) : ModelLoaderFactory { + override fun build( + multiFactory: MultiModelLoaderFactory + ): ModelLoader { + return SizeObservingFakeModelLoader( + multiFactory.build(File::class.java, File::class.java), + fakeResult, + sizeReference, + ) + } + + override fun teardown() {} + } + } +} + +private fun atMostOnce(function: () -> Unit): () -> Unit { + var isCalled = false + return { + if (!isCalled) { + isCalled = true + function() + } + } +} + +private fun onSuccess(onSuccess: () -> Unit) = + simpleRequestListener(onSuccess) {} + +private fun onFailure(onFailure: () -> Unit) = + simpleRequestListener({}, onFailure) + +private fun simpleRequestListener( + onSuccess: () -> Unit, + onFailure: () -> Unit, +): RequestListener = + object : RequestListener { + override fun onResourceReady( + resource: ResourceT?, + model: Any?, + target: Target?, + dataSource: DataSource?, + isFirstResource: Boolean, + ): Boolean { + onSuccess() + return false + } + + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target?, + isFirstResource: Boolean, + ): Boolean { + onFailure() + return false + } + } + +// TODO(judds): This function may be useful in production code as well, consider exposing it. +private fun Flow>.firstLoad(): + Flow> { + val originalFlow = this + return flow { + var completion: GlideFlowInstant? = null + originalFlow + .takeWhile { + if (it.status != Status.SUCCEEDED && it.status != Status.FAILED) { + true + } else { + completion = it + false + } + } + .collect { emit(it) } + + emit(completion!!) + } +} + +@OptIn(DelicateCoroutinesApi::class) +private fun restartAllRequestsOnNewThread(requestManager: RequestManager) = + newSingleThreadContext("restart").use { + it.executor.execute { + requestManager.pauseAllRequests() + requestManager.resumeRequests() + } + } + +private fun placeholder(status: Status) = StatusAndType(status, Placeholder::class) + +private fun resource(status: Status) = StatusAndType(status, Resource::class) + +private data class StatusAndType(val status: Status, val type: KClass>) + +private fun IterableSubject.compareStatusAndType() = comparingElementsUsing(statusAndType()) + +private fun statusAndType(): Correspondence, StatusAndType> = + ktCorrespondenceFrom("statusAndType") { actual, expected -> + actual?.statusAndType() == expected + } + +private fun GlideFlowInstant<*>.statusAndType() = + StatusAndType( + status, + when (this) { + is Placeholder<*> -> Placeholder::class + is Resource<*> -> Resource::class + }, + ) + +private fun IterableSubject.comparingStatus() = comparingElementsUsing(status()) + +private fun status(): Correspondence, Status> = + ktCorrespondenceFrom("status") { actual, expected -> actual?.status == expected } + +private fun ktCorrespondenceFrom( + description: String, + predicate: Correspondence.BinaryPredicate, +) = Correspondence.from(predicate, description) diff --git a/integration/ktx/src/test/java/com/bumptech/glide/load/engine/executor/GlideIdlingResourceInit.kt b/integration/ktx/src/test/java/com/bumptech/glide/load/engine/executor/GlideIdlingResourceInit.kt new file mode 100644 index 0000000000..644aef80b3 --- /dev/null +++ b/integration/ktx/src/test/java/com/bumptech/glide/load/engine/executor/GlideIdlingResourceInit.kt @@ -0,0 +1,36 @@ +package com.bumptech.glide.load.engine.executor + +import androidx.test.core.app.ApplicationProvider +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.idling.concurrent.IdlingThreadPoolExecutor +import com.bumptech.glide.Glide +import com.bumptech.glide.GlideBuilder +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +object GlideIdlingResources { + + fun initGlide(builder: GlideBuilder? = null) { + val registry = IdlingRegistry.getInstance() + val executor = + IdlingThreadPoolExecutor( + "glide_test_thread", + /* corePoolSize = */ 1, + /* maximumPoolSize = */ 1, + /* keepAliveTime = */ 1, + TimeUnit.SECONDS, + LinkedBlockingQueue(), + ) { + Thread(it) + } + val glideExecutor = GlideExecutor(executor) + Glide.init( + ApplicationProvider.getApplicationContext(), + (builder ?: GlideBuilder()) + .setSourceExecutor(glideExecutor) + .setAnimationExecutor(glideExecutor) + .setDiskCacheExecutor(glideExecutor), + ) + registry.register(executor) + } +} diff --git a/integration/okhttp/build.gradle b/integration/okhttp/build.gradle deleted file mode 100644 index 6931cb0aec..0000000000 --- a/integration/okhttp/build.gradle +++ /dev/null @@ -1,27 +0,0 @@ -apply plugin: 'com.android.library' - -dependencies { - implementation project(':library') - annotationProcessor project(':annotation:compiler') - - api "com.squareup.okhttp:okhttp:2.7.5" - api "androidx.annotation:annotation:${ANDROID_X_ANNOTATION_VERSION}" -} - -android { - compileSdkVersion COMPILE_SDK_VERSION as int - - defaultConfig { - minSdkVersion MIN_SDK_VERSION as int - targetSdkVersion TARGET_SDK_VERSION as int - - versionName VERSION_NAME as String - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 - } -} - -apply from: "${rootProject.projectDir}/scripts/upload.gradle" diff --git a/integration/okhttp/build.gradle.kts b/integration/okhttp/build.gradle.kts new file mode 100644 index 0000000000..8cb793bceb --- /dev/null +++ b/integration/okhttp/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "com.bumptech.glide.integration.okhttp" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation(project(":library")) + annotationProcessor(project(":annotation:compiler")) + + api(libs.okhttp2) + api(libs.androidx.annotation) +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") \ No newline at end of file diff --git a/integration/okhttp/src/main/AndroidManifest.xml b/integration/okhttp/src/main/AndroidManifest.xml deleted file mode 100644 index 62fac40a32..0000000000 --- a/integration/okhttp/src/main/AndroidManifest.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/integration/okhttp3/build.gradle b/integration/okhttp3/build.gradle deleted file mode 100644 index a74c387b11..0000000000 --- a/integration/okhttp3/build.gradle +++ /dev/null @@ -1,27 +0,0 @@ -apply plugin: 'com.android.library' - -dependencies { - implementation project(':library') - annotationProcessor project(':annotation:compiler') - - api "com.squareup.okhttp3:okhttp:${OK_HTTP_VERSION}" - api "androidx.annotation:annotation:${ANDROID_X_ANNOTATION_VERSION}" -} - -android { - compileSdkVersion COMPILE_SDK_VERSION as int - - defaultConfig { - minSdkVersion MIN_SDK_VERSION as int - targetSdkVersion TARGET_SDK_VERSION as int - - versionName VERSION_NAME as String - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 - } -} - -apply from: "${rootProject.projectDir}/scripts/upload.gradle" diff --git a/integration/okhttp3/build.gradle.kts b/integration/okhttp3/build.gradle.kts new file mode 100644 index 0000000000..c1bb4e0542 --- /dev/null +++ b/integration/okhttp3/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "com.bumptech.glide.integration.okhttp" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation(project(":library")) + annotationProcessor(project(":annotation:compiler")) + + api(libs.okhttp3) + api(libs.androidx.annotation) +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") \ No newline at end of file diff --git a/integration/okhttp3/src/main/AndroidManifest.xml b/integration/okhttp3/src/main/AndroidManifest.xml index bc8e5a72c3..9bd246d78e 100644 --- a/integration/okhttp3/src/main/AndroidManifest.xml +++ b/integration/okhttp3/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ - + + + + + + diff --git a/integration/okhttp4/src/main/java/com/bumptech/glide/integration/okhttp3/OkHttpLibraryGlideModule.java b/integration/okhttp4/src/main/java/com/bumptech/glide/integration/okhttp3/OkHttpLibraryGlideModule.java new file mode 100644 index 0000000000..0d605ce9ce --- /dev/null +++ b/integration/okhttp4/src/main/java/com/bumptech/glide/integration/okhttp3/OkHttpLibraryGlideModule.java @@ -0,0 +1,26 @@ +package com.bumptech.glide.integration.okhttp3; + +import android.content.Context; +import androidx.annotation.NonNull; +import com.bumptech.glide.Glide; +import com.bumptech.glide.Registry; +import com.bumptech.glide.annotation.GlideModule; +import com.bumptech.glide.load.model.GlideUrl; +import com.bumptech.glide.module.AppGlideModule; +import com.bumptech.glide.module.LibraryGlideModule; +import java.io.InputStream; + +/** + * Registers OkHttp related classes via Glide's annotation processor. + * + *

For Applications that depend on this library and include an {@link AppGlideModule} and Glide's + * annotation processor, this class will be automatically included. + */ +@GlideModule +public final class OkHttpLibraryGlideModule extends LibraryGlideModule { + @Override + public void registerComponents( + @NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { + registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory()); + } +} diff --git a/integration/okhttp4/src/main/java/com/bumptech/glide/integration/okhttp3/OkHttpStreamFetcher.java b/integration/okhttp4/src/main/java/com/bumptech/glide/integration/okhttp3/OkHttpStreamFetcher.java new file mode 100644 index 0000000000..ac9ca2bbd5 --- /dev/null +++ b/integration/okhttp4/src/main/java/com/bumptech/glide/integration/okhttp3/OkHttpStreamFetcher.java @@ -0,0 +1,109 @@ +package com.bumptech.glide.integration.okhttp3; + +import android.util.Log; +import androidx.annotation.NonNull; +import com.bumptech.glide.Priority; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.HttpException; +import com.bumptech.glide.load.data.DataFetcher; +import com.bumptech.glide.load.model.GlideUrl; +import com.bumptech.glide.util.ContentLengthInputStream; +import com.bumptech.glide.util.Preconditions; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import okhttp3.Call; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** Fetches an {@link InputStream} using the okhttp library. */ +public class OkHttpStreamFetcher implements DataFetcher, okhttp3.Callback { + private static final String TAG = "OkHttpFetcher"; + private final Call.Factory client; + private final GlideUrl url; + private InputStream stream; + private ResponseBody responseBody; + private DataCallback callback; + // call may be accessed on the main thread while the object is in use on other threads. All other + // accesses to variables may occur on different threads, but only one at a time. + private volatile Call call; + + // Public API. + @SuppressWarnings("WeakerAccess") + public OkHttpStreamFetcher(Call.Factory client, GlideUrl url) { + this.client = client; + this.url = url; + } + + @Override + public void loadData( + @NonNull Priority priority, @NonNull final DataCallback callback) { + Request.Builder requestBuilder = new Request.Builder().url(url.toStringUrl()); + for (Map.Entry headerEntry : url.getHeaders().entrySet()) { + String key = headerEntry.getKey(); + requestBuilder.addHeader(key, headerEntry.getValue()); + } + Request request = requestBuilder.build(); + this.callback = callback; + + call = client.newCall(request); + call.enqueue(this); + } + + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "OkHttp failed to obtain result", e); + } + + callback.onLoadFailed(e); + } + + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + responseBody = response.body(); + if (response.isSuccessful()) { + long contentLength = Preconditions.checkNotNull(responseBody).contentLength(); + stream = ContentLengthInputStream.obtain(responseBody.byteStream(), contentLength); + callback.onDataReady(stream); + } else { + callback.onLoadFailed(new HttpException(response.message(), response.code())); + } + } + + @Override + public void cleanup() { + try { + if (stream != null) { + stream.close(); + } + } catch (IOException e) { + // Ignored + } + if (responseBody != null) { + responseBody.close(); + } + callback = null; + } + + @Override + public void cancel() { + Call local = call; + if (local != null) { + local.cancel(); + } + } + + @NonNull + @Override + public Class getDataClass() { + return InputStream.class; + } + + @NonNull + @Override + public DataSource getDataSource() { + return DataSource.REMOTE; + } +} diff --git a/integration/okhttp4/src/main/java/com/bumptech/glide/integration/okhttp3/OkHttpUrlLoader.java b/integration/okhttp4/src/main/java/com/bumptech/glide/integration/okhttp3/OkHttpUrlLoader.java new file mode 100644 index 0000000000..6eb9823779 --- /dev/null +++ b/integration/okhttp4/src/main/java/com/bumptech/glide/integration/okhttp3/OkHttpUrlLoader.java @@ -0,0 +1,78 @@ +package com.bumptech.glide.integration.okhttp3; + +import androidx.annotation.NonNull; +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 java.io.InputStream; +import okhttp3.Call; +import okhttp3.OkHttpClient; + +/** A simple model loader for fetching media over http/https using OkHttp. */ +public class OkHttpUrlLoader implements ModelLoader { + + private final Call.Factory client; + + // Public API. + @SuppressWarnings("WeakerAccess") + public OkHttpUrlLoader(@NonNull Call.Factory client) { + this.client = client; + } + + @Override + public boolean handles(@NonNull GlideUrl url) { + return true; + } + + @Override + public LoadData buildLoadData( + @NonNull GlideUrl model, int width, int height, @NonNull Options options) { + return new LoadData<>(model, new OkHttpStreamFetcher(client, model)); + } + + /** The default factory for {@link OkHttpUrlLoader}s. */ + // Public API. + @SuppressWarnings("WeakerAccess") + public static class Factory implements ModelLoaderFactory { + private static volatile Call.Factory internalClient; + private final Call.Factory client; + + private static Call.Factory getInternalClient() { + if (internalClient == null) { + synchronized (Factory.class) { + if (internalClient == null) { + internalClient = new OkHttpClient(); + } + } + } + return internalClient; + } + + /** Constructor for a new Factory that runs requests using a static singleton client. */ + public Factory() { + this(getInternalClient()); + } + + /** + * Constructor for a new Factory that runs requests using given client. + * + * @param client this is typically an instance of {@code OkHttpClient}. + */ + public Factory(@NonNull Call.Factory client) { + this.client = client; + } + + @NonNull + @Override + public ModelLoader build(MultiModelLoaderFactory multiFactory) { + return new OkHttpUrlLoader(client); + } + + @Override + public void teardown() { + // Do nothing, this instance doesn't own the client. + } + } +} diff --git a/integration/recyclerview/build.gradle b/integration/recyclerview/build.gradle deleted file mode 100644 index 4a0fbee5cd..0000000000 --- a/integration/recyclerview/build.gradle +++ /dev/null @@ -1,25 +0,0 @@ -apply plugin: 'com.android.library' - -dependencies { - implementation project(':library') - compileOnly "androidx.recyclerview:recyclerview:${ANDROID_X_VERSION}" - compileOnly "androidx.fragment:fragment:${ANDROID_X_FRAGMENT_VERSION}" -} - -android { - compileSdkVersion COMPILE_SDK_VERSION as int - - defaultConfig { - minSdkVersion MIN_SDK_VERSION as int - targetSdkVersion TARGET_SDK_VERSION as int - - versionName VERSION_NAME as String - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 - } -} - -apply from: "${rootProject.projectDir}/scripts/upload.gradle" diff --git a/integration/recyclerview/build.gradle.kts b/integration/recyclerview/build.gradle.kts new file mode 100644 index 0000000000..d2bc779a08 --- /dev/null +++ b/integration/recyclerview/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "com.bumptech.glide.integration.recyclerview" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } +} + + +dependencies { + implementation(project(":library")) + compileOnly(libs.androidx.recyclerview) + compileOnly(libs.androidx.fragment) +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") \ No newline at end of file diff --git a/integration/recyclerview/src/main/AndroidManifest.xml b/integration/recyclerview/src/main/AndroidManifest.xml deleted file mode 100644 index ec07ec85f9..0000000000 --- a/integration/recyclerview/src/main/AndroidManifest.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/integration/recyclerview/src/main/java/com/bumptech/glide/integration/recyclerview/RecyclerToListViewScrollListener.java b/integration/recyclerview/src/main/java/com/bumptech/glide/integration/recyclerview/RecyclerToListViewScrollListener.java index c91ff05c21..8039d677ad 100644 --- a/integration/recyclerview/src/main/java/com/bumptech/glide/integration/recyclerview/RecyclerToListViewScrollListener.java +++ b/integration/recyclerview/src/main/java/com/bumptech/glide/integration/recyclerview/RecyclerToListViewScrollListener.java @@ -10,7 +10,7 @@ * Converts {@link androidx.recyclerview.widget.RecyclerView.OnScrollListener} events to {@link * AbsListView} scroll events. * - *

Requires that the the recycler view be using a {@link LinearLayoutManager} subclass. + *

Requires that the recycler view be using a {@link LinearLayoutManager} subclass. */ // Public API. @SuppressWarnings("WeakerAccess") diff --git a/integration/sqljournaldiskcache/build.gradle.kts b/integration/sqljournaldiskcache/build.gradle.kts new file mode 100644 index 0000000000..359cd56d84 --- /dev/null +++ b/integration/sqljournaldiskcache/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { id("com.android.library") } + +android { + namespace = "com.bumptech.glide.integration.sqljournaldiskcache" + + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { minSdk = libs.versions.min.sdk.version.get().toInt() } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation(project(":library")) + implementation(libs.errorprone.annotations) + + testImplementation(libs.guava.testlib) + testImplementation(libs.truth) + testImplementation(libs.junit) + testImplementation(libs.mockito.core) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.androidx.test.runner) +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") diff --git a/integration/sqljournaldiskcache/gradle.properties b/integration/sqljournaldiskcache/gradle.properties new file mode 100644 index 0000000000..cab9b276e9 --- /dev/null +++ b/integration/sqljournaldiskcache/gradle.properties @@ -0,0 +1,4 @@ +POM_NAME=Glide SQL Journaled Disk Cache +POM_ARTIFACT_ID=sqljournaldiskcache +POM_PACKAGING=aar +POM_DESCRIPTION=A sql journaled LRU disk cache alternative to Glide's standard disk cache diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/Clock.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/Clock.java new file mode 100644 index 0000000000..57f4db94a4 --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/Clock.java @@ -0,0 +1,12 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +/** + * A simple wrapper for obtaining the current time for testing. + * + *

While this interface exists in lots of libraries, especially internally at Google, there + * doesn't seem to be a reasonable public version. For now we're just duplicating it again in Glide + * so that the library can be open sourced. + */ +public interface Clock { + long currentTimeMillis(); +} diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/DefaultClock.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/DefaultClock.java new file mode 100644 index 0000000000..91c7a0c6bc --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/DefaultClock.java @@ -0,0 +1,8 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +final class DefaultClock implements Clock { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } +} diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/DiskCacheDbHelper.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/DiskCacheDbHelper.java new file mode 100644 index 0000000000..c2d833372f --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/DiskCacheDbHelper.java @@ -0,0 +1,80 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import androidx.annotation.VisibleForTesting; + +/** The database helper for managing tables for {@link JournaledLruDiskCache}. */ +final class DiskCacheDbHelper extends SQLiteOpenHelper { + private static final int DATABASE_VERSION = 2; // judds. + private static final String DATABASE_NAME = "disk_cache"; + + static DiskCacheDbHelper forProd(Context context) { + return new DiskCacheDbHelper(context, /* isInMemory= */ false); + } + + static DiskCacheDbHelper forTesting(Context context) { + return new DiskCacheDbHelper(context, /* isInMemory= */ true); + } + + private DiskCacheDbHelper(Context context, boolean isInMemory) { + this(context, isInMemory, DATABASE_VERSION); + } + + @VisibleForTesting + DiskCacheDbHelper(Context context, boolean isInMemory, int databaseVersion) { + super(context, isInMemory ? null : DATABASE_NAME, /* factory= */ null, databaseVersion); + setWriteAheadLoggingEnabled(true); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(JournalTable.getSqlCreateStatement()); + db.execSQL(JournalTable.getIndexString()); + db.execSQL(SizeTable.getSqlCreateStatement()); + } + + @Override + public void onOpen(SQLiteDatabase db) { + db.execSQL("PRAGMA legacy_alter_table=ON"); + db.setForeignKeyConstraintsEnabled(false); + try { + super.onOpen(db); + } finally { + db.setForeignKeyConstraintsEnabled(true); + } + } + + // We're matching the existing production behavior, which uses STRING even though it should use + // TEXT + @SuppressLint("SQLiteString") + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + if (oldVersion < 2) { + // Dropping the journal table will also drop the index: https://sqlite.org/lang_droptable.html + db.execSQL("DROP TABLE IF EXISTS journal"); + db.execSQL("DROP TABLE IF EXISTS size"); + + db.execSQL( + "CREATE TABLE journal(" + + "key STRING PRIMARY KEY, " + + "last_modified_time INTEGER NOT NULL, " + + "pending_delete INTEGER NOT NULL DEFAULT 0, " + + "size INTEGER NOT NULL" + + ")"); + db.execSQL( + "CREATE INDEX journal_timestamp_key_idx" + + " ON journal (" + + "last_modified_time, " + + "key" + + ")"); + db.execSQL( + "CREATE TABLE size(" + + "id INTEGER PRIMARY KEY, " + + "size INTEGER NOT NULL DEFAULT 0" + + ")"); + } + } +} diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/EntryCache.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/EntryCache.java new file mode 100644 index 0000000000..83a3c1852d --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/EntryCache.java @@ -0,0 +1,127 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +import androidx.collection.ArrayMap; +import com.bumptech.glide.util.LruCache; +import java.io.File; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** + * Maintains an LRU cache of {@link String} keys to {@link Entry Entrys} where each entry contains a + * read/write lock used to guarantee state and entries that are currently locked are guaranteed not + * to be evicted. + */ +final class EntryCache { + private final ArrayMap activeEntries = new ArrayMap<>(); + private final LruCache inactiveEntries = new LruCache<>(6000); + + synchronized void clear() { + activeEntries.clear(); + inactiveEntries.clearMemory(); + } + + synchronized Entry get(String key) { + Entry entry = activeEntries.get(key); + if (entry == null) { + entry = inactiveEntries.get(key); + if (entry == null) { + entry = new Entry(key, this); + activeEntries.put(key, entry); + } + } + return entry; + } + + private synchronized void removeFromActive(Entry entry) { + activeEntries.remove(entry.key); + inactiveEntries.put(entry.key, entry); + } + + private synchronized void addToActive(Entry entry) { + inactiveEntries.remove(entry.key); + activeEntries.put(entry.key, entry); + } + + static final class Entry { + private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); + private final String key; + private final EntryCache cache; + + private int lockCount; + private State state = State.UNKNOWN; + private File file; + + Entry(String key, EntryCache cache) { + this.key = key; + this.cache = cache; + } + + File getFile() { + return file; + } + + boolean isStateKnown() { + return state != State.UNKNOWN; + } + + boolean isPresent() { + return state == State.PRESENT; + } + + void setPresent(File file) { + this.file = file; + state = State.PRESENT; + } + + void setUnknown() { + state = State.UNKNOWN; + } + + void setNotPresent() { + state = State.NOT_PRESENT; + } + + void acquireReadLock() { + maybeSetActive(); + readWriteLock.readLock().lock(); + } + + void releaseReadLock() { + ReentrantReadWriteLock lock = readWriteLock; + maybeSetInactive(); + lock.readLock().unlock(); + } + + void acquireWriteLock() { + maybeSetActive(); + readWriteLock.writeLock().lock(); + } + + void releaseWriteLock() { + ReentrantReadWriteLock lock = readWriteLock; + maybeSetInactive(); + lock.writeLock().unlock(); + } + + private synchronized void maybeSetActive() { + lockCount++; + if (lockCount == 1) { + cache.addToActive(this); + readWriteLock = new ReentrantReadWriteLock(); + } + } + + private synchronized void maybeSetInactive() { + lockCount--; + if (lockCount == 0) { + cache.removeFromActive(this); + readWriteLock = null; + } + } + + private enum State { + UNKNOWN, + PRESENT, + NOT_PRESENT, + } + } +} diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/EvictionManager.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/EvictionManager.java new file mode 100644 index 0000000000..f87cc7f4a7 --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/EvictionManager.java @@ -0,0 +1,165 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.util.Log; +import androidx.annotation.GuardedBy; +import java.io.File; +import java.util.List; + +final class EvictionManager { + private static final String TAG = "Evictor"; + // You must restart the app after enabling these logs for the change to take affect. + // We cache isLoggable to avoid the performance hit of checking repeatedly. + private static final boolean LOG_DEBUG = Log.isLoggable(TAG, Log.DEBUG); + private static final boolean LOG_VERBOSE = Log.isLoggable(TAG, Log.VERBOSE); + + // The maximum amount we can go over our cache size before triggering evictions, currently 25mb. + private static final long MAXIMUM_EVICTION_SLOP = 25 * 1024 * 1024; + + private final Handler evictionHandler; + private final JournaledLruDiskCache diskCache; + private final File cacheDirectory; + private final FileSystem fileSystem; + private final Journal journal; + private final Looper workLooper; + private final Clock clock; + private final long evictionSlopBytes; + private final long staleEvictionThresholdMs; + + @GuardedBy("this") + private long maximumSizeBytes; + + EvictionManager( + JournaledLruDiskCache diskCache, + File cacheDirectory, + FileSystem fileSystem, + Journal journal, + Looper workLooper, + long maximumSizeBytes, + float slopMultiplier, + long staleEvictionThresholdMs, + Clock clock) { + this.diskCache = diskCache; + this.cacheDirectory = cacheDirectory; + this.fileSystem = fileSystem; + this.journal = journal; + this.workLooper = workLooper; + this.maximumSizeBytes = maximumSizeBytes; + this.clock = clock; + this.staleEvictionThresholdMs = staleEvictionThresholdMs; + + evictionSlopBytes = + Math.min(Math.round(maximumSizeBytes * slopMultiplier), MAXIMUM_EVICTION_SLOP); + evictionHandler = new Handler(workLooper, new EvictionCallback()); + } + + /** + * Sets maximumSizeBytes to a new size. + * + *

Must be called on a background thread. + * + *

Decreasing the maximumSizeBytes may schedule an eviction if the current cache size exceeds + * the new maximumSizeBytes. Evictions will be scheduled and executed asynchronously. Therefore, + * the eviction will happen based on the latest maximum cache size, not the maximum size at + * scheduling. + */ + synchronized void setMaximumSizeBytes(long newMaxSizeBytes) { + long originalMaxBytes = maximumSizeBytes; + maximumSizeBytes = newMaxSizeBytes; + if (newMaxSizeBytes < originalMaxBytes) { + maybeScheduleEviction(newMaxSizeBytes); + } + } + + private synchronized long getMaximumSizeBytes() { + return maximumSizeBytes; + } + + /** + * Schedules a journal eviction on a work thread if the journal size currently exceeds the allowed + * cache size. + */ + void maybeScheduleEviction() { + maybeScheduleEviction(getMaximumSizeBytes()); + } + + private void maybeScheduleEviction(long maximumSizeBytes) { + if (isEvictionRequired(maximumSizeBytes)) { + evictionHandler.obtainMessage(MessageIds.EVICT).sendToTarget(); + } + } + + private boolean isEvictionRequired(long maximumSizeBytes) { + return journal.getCurrentSizeBytes() > evictionSlopBytes + maximumSizeBytes; + } + + private void evictOnWorkThread() { + if (!Looper.myLooper().equals(workLooper)) { + throw new IllegalStateException( + "Cannot call evictOnWorkThread on thread: " + Thread.currentThread().getName()); + } + long maximumSizeBytes = getMaximumSizeBytes(); + long staleDateMs = clock.currentTimeMillis() - staleEvictionThresholdMs; + List staleEntriesKeys = journal.getStaleEntries(staleDateMs); + // Writes may queue up a number of eviction messages. After the first one runs, eviction may no + // longer be necessary, so we simply ignore the message. + if (!isEvictionRequired(maximumSizeBytes) && staleEntriesKeys.isEmpty()) { + if (LOG_VERBOSE) { + Log.v(TAG, "Ignoring eviction, not needed"); + } + return; + } + if (LOG_DEBUG) { + Log.d(TAG, "Starting eviction on work thread"); + } + + int successfullyDeletedCount = 0; + int triedToDeleteEntries = staleEntriesKeys.size(); + if (!staleEntriesKeys.isEmpty()) { + successfullyDeletedCount += diskCache.delete(staleEntriesKeys).size(); + } + + long targetSize = maximumSizeBytes - evictionSlopBytes; + if (isEvictionRequired(maximumSizeBytes)) { + long bytesToEvict = journal.getCurrentSizeBytes() - targetSize; + List leastRecentlyUsedKeys = journal.getLeastRecentlyUsed(bytesToEvict); + triedToDeleteEntries += leastRecentlyUsedKeys.size(); + successfullyDeletedCount += diskCache.delete(leastRecentlyUsedKeys).size(); + } + + if (triedToDeleteEntries == 0) { + throw new IllegalStateException("Failed to find entries to evict."); + } + + if (LOG_DEBUG) { + Log.d( + TAG, + "Ran eviction" + + ", tried to delete: " + + triedToDeleteEntries + + " entries" + + ", actually deleted: " + + successfullyDeletedCount + + " entries" + + ", target journal : " + + targetSize + + ", journal size: " + + journal.getCurrentSizeBytes() + + ", file size: " + + fileSystem.getDirectorySize(cacheDirectory)); + } + } + + private class EvictionCallback implements Handler.Callback { + @Override + public boolean handleMessage(Message msg) { + if (msg.what != MessageIds.EVICT) { + return false; + } + evictOnWorkThread(); + return true; + } + } +} diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/FileSystem.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/FileSystem.java new file mode 100644 index 0000000000..9ceb99c559 --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/FileSystem.java @@ -0,0 +1,59 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +import java.io.File; +import java.io.IOException; + +/** + * Wraps a few common {@link File} methods to provide a few higher level functions and allow for + * mocking uncommon error cases. + */ +interface FileSystem { + + default boolean delete(File file) { + return file.delete(); + } + + default boolean exists(File file) { + return file.exists(); + } + + default boolean createNewFile(File file) throws IOException { + return file.createNewFile(); + } + + default boolean rename(File from, File to) { + return from.renameTo(to); + } + + default long length(File file) { + return file.length(); + } + + default long getDirectorySize(File file) { + long size = 0; + if (file.isDirectory()) { + for (File f : file.listFiles()) { + size += getDirectorySize(f); + } + } else { + size = file.length(); + } + return size; + } + + default boolean deleteAll(File file) { + boolean result = true; + if (file.isDirectory()) { + for (File f : file.listFiles()) { + result = deleteAll(f) && result; + } + } else { + result = file.delete(); + } + return result; + } + + default boolean setLastModified(File file, long newLastModifiedTime) { + return file.setLastModified(newLastModifiedTime); + } +} diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/GlideJournaledLruDiskCacheWrapper.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/GlideJournaledLruDiskCacheWrapper.java new file mode 100644 index 0000000000..d11eb01fb6 --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/GlideJournaledLruDiskCacheWrapper.java @@ -0,0 +1,113 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import com.bumptech.glide.load.Key; +import com.bumptech.glide.load.engine.cache.DiskCache; +import com.bumptech.glide.load.engine.cache.SafeKeyGenerator; +import com.bumptech.glide.util.Util; +import java.io.File; + +/** Implements {@link DiskCache} using {@link JournaledLruDiskCache}. */ +public final class GlideJournaledLruDiskCacheWrapper implements DiskCache { + // 500 mb + private static final long DEFAULT_GLIDE_CACHE_SIZE_BYTES = 1024 * 1024 * 500; + public static final String DEFAULT_CACHE_DIR = "glide_cache"; + + public static final String KEY_VALUE_STORE_PREFIX = + "com.google.android.apps.photos.diskcache.GlideJournaledLruDiskCacheWrapper"; + + private final JournaledLruDiskCache diskCache; + private final SafeKeyGenerator safeKeyGenerator; + private final DiskCacheDbHelper diskCacheDbHelper; + + public static GlideJournaledLruDiskCacheWrapper newInstance(Context context, File diskCacheDir) { + return newInstance( + context, + diskCacheDir, + // Default to not evicting based on entry age. + /* staleEvictionThresholdMs= */ Long.MAX_VALUE, + new DefaultClock()); + } + + public static GlideJournaledLruDiskCacheWrapper newInstance( + Context context, File diskCacheDir, long staleEvictionThresholdMs, Clock clock) { + return new GlideJournaledLruDiskCacheWrapper( + diskCacheDir, DiskCacheDbHelper.forProd(context), staleEvictionThresholdMs, clock); + } + + private GlideJournaledLruDiskCacheWrapper( + File diskCacheDir, + DiskCacheDbHelper diskCacheDbHelper, + long staleEvictionThresholdMs, + Clock clock) { + this.diskCacheDbHelper = diskCacheDbHelper; + this.safeKeyGenerator = new SafeKeyGenerator(); + this.diskCache = + new JournaledLruDiskCache( + diskCacheDir, + diskCacheDbHelper, + DEFAULT_GLIDE_CACHE_SIZE_BYTES, + staleEvictionThresholdMs, + clock); + } + + /** + * Sets the maximum size of the cache to a new size in bytes. + * + *

Must be called on a background thread. + * + *

The JournaledLruDiskCache manages the sizing of the cache. Decreasing the size may schedule + * an eviction if the current cache size exceeds newMaximumSizeBytes. Evictions will be scheduled + * and executed asynchronously. Therefore, the eviction will happen based on the latest maximum + * cache size, not the maximum size at scheduling. + */ + public void setMaximumSizeBytes(long newMaximumSizeBytes) { + Util.assertBackgroundThread(); + diskCache.setMaximumSizeBytes(newMaximumSizeBytes); + } + + @Override + public File get(Key key) { + String safeKey = safeKeyGenerator.getSafeKey(key); + return diskCache.get(safeKey); + } + + @Override + public void put(Key key, Writer writer) { + String safeKey = safeKeyGenerator.getSafeKey(key); + File tempFile = diskCache.beginPut(safeKey); + // Edit already in progress, or file is already written. + try { + if (tempFile != null && writer.write(tempFile)) { + diskCache.commitPut(safeKey, tempFile); + } + } finally { + diskCache.abortPutIfNotCommitted(safeKey, tempFile); + } + } + + @Override + public void delete(Key key) { + String safeKey = safeKeyGenerator.getSafeKey(key); + diskCache.delete(safeKey); + } + + @Override + public void clear() { + diskCache.clear(); + } + + /** + * @deprecated this method will be replaced by a more specific version + */ + @Deprecated + public SQLiteDatabase getWritableDatabase() { + return diskCacheDbHelper.getWritableDatabase(); + } + + /** Returns number of bytes used by the JournaledLruDiskCache currently. */ + public long getCurrentSizeBytes() { + return diskCache.getCurrentSizeBytes(); + } +} diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/Journal.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/Journal.java new file mode 100644 index 0000000000..867ac7361a --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/Journal.java @@ -0,0 +1,514 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteDoneException; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteStatement; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.text.TextUtils; +import android.util.Log; +import com.bumptech.glide.integration.sqljournaldiskcache.SizeJournal.SizeSQLiteTransactionListener; +import com.bumptech.glide.util.Preconditions; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +final class Journal { + private static final String TAG = "Journal"; + + // You must restart the app after enabling these logs for the change to take affect. + // We cache isLoggable to avoid the performance hit of checking repeatedly. + private static final boolean LOG_VERBOSE = Log.isLoggable(TAG, Log.VERBOSE); + private static final boolean LOG_DEBUG = Log.isLoggable(TAG, Log.DEBUG); + private static final boolean LOG_WARN = Log.isLoggable(TAG, Log.WARN); + + private static final String ROW_ID = "rowid"; + private static final String WHERE_KEY = JournalTable.Columns.KEY + " = ?"; + private static final String WHERE_PENDING_DELETE = JournalTable.Columns.PENDING_DELETE + " != 0"; + + // If a commit fails (renameTo returns false) and then the app dies before the commit is aborted, + // we will end up with a temp file and an entry in the journal for a key. New puts for that key + // should be able to complete successfully, so we use insert or replace to allow the entry to be + // updated. We don't normally expect to be replacing entries. + private static final String INSERT_NEW_KEY_SQL = + "INSERT OR REPLACE INTO " + + JournalTable.TABLE_NAME + + "(" + + JournalTable.Columns.KEY + + ", " + + JournalTable.Columns.LAST_MODIFIED_TIME + + ", " + + JournalTable.Columns.SIZE + + ") VALUES (?, ?, ?)"; + private static final int INSERT_NEW_KEY_KEY_IDX = 1; + private static final int INSERT_NEW_KEY_MODIFIED_TIME_IDX = 2; + private static final int INSERT_NEW_KEY_SIZE_IDX = 3; + + private static final String CONTAINS_KEY_SQL = + "SELECT COUNT(*) FROM " + JournalTable.TABLE_NAME + " WHERE " + WHERE_KEY; + private static final int CONTAINS_KEY_KEY_IDX = 1; + + private static final String SELECT_ENTRY_SIZE_NOT_PENDING_SQL = + "SELECT " + + JournalTable.Columns.SIZE + + " FROM " + + JournalTable.TABLE_NAME + + " WHERE " + + WHERE_KEY + + " AND " + + JournalTable.Columns.PENDING_DELETE + + " = 0"; + private static final int SELECT_ENTRY_SIZE_NOT_PENDING_KEY_IDX = 1; + + private static final String DELETE_ENTRY_SQL = + "DELETE FROM " + JournalTable.TABLE_NAME + " WHERE " + WHERE_KEY; + private static final int DELETE_ENTRY_KEY_IDX = 1; + + private static final String[] LRU_PROJECTION = + new String[] {JournalTable.Columns.KEY, JournalTable.Columns.SIZE}; + private static final String[] STALE_PROJECTION = + new String[] {JournalTable.Columns.KEY, JournalTable.Columns.LAST_MODIFIED_TIME, ROW_ID}; + private static final String LRU_WHERE = JournalTable.Columns.PENDING_DELETE + " = 0"; + private static final String STALE_WHERE = + ROW_ID + " > ? AND " + JournalTable.Columns.LAST_MODIFIED_TIME + " < ?"; + // rowid comes from https://www.sqlite.org/rowidtable.html. See b/206890186. + private static final String LRU_ORDER_BY = + JournalTable.Columns.LAST_MODIFIED_TIME + " ASC, rowid ASC"; + private static final String STALE_ORDER_BY = ROW_ID + " ASC"; + private static final int LRU_BATCH_SIZE = 25; + private static final int STALE_BATCH_SIZE = 25; + + private static final String SUM_SIZE_WHERE_NOT_PENDING_DELETE = + "SELECT SUM(" + + JournalTable.Columns.SIZE + + ") FROM " + + JournalTable.TABLE_NAME + + " WHERE " + + JournalTable.Columns.PENDING_DELETE + + " = 0"; + + private static final String[] PENDING_DELETE_PROJECTION = new String[] {JournalTable.Columns.KEY}; + + private static final int DELETE_BATCH_SIZE = 200; + + private final DiskCacheDbHelper dbHelper; + private final SqliteStatementPool statementPool; + private final Clock clock; + private final Handler updateTimesHandler; + private final SizeJournal sizeJournal; + + Journal( + DiskCacheDbHelper dbHelper, + Looper workThreadLooper, + int updateModifiedTimeBatchSize, + Clock clock) { + this.dbHelper = dbHelper; + this.sizeJournal = new SizeJournal(dbHelper); + statementPool = new SqliteStatementPool(dbHelper); + this.clock = clock; + + updateTimesHandler = + new Handler( + workThreadLooper, + new UpdateTimesCallback(dbHelper, updateModifiedTimeBatchSize, clock)); + } + + long getCurrentSizeBytes() { + return sizeJournal.getCacheSizeBytes(); + } + + void open() { + sizeJournal.open(); + } + + void clear() { + SQLiteDatabase db = dbHelper.getWritableDatabase(); + db.beginTransactionNonExclusive(); + try { + db.delete(JournalTable.TABLE_NAME, null /*whereClause*/, null /*whereArgs*/); + sizeJournal.clearInTransaction(); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + void get(String key) { + updateTimesHandler.obtainMessage(MessageIds.ADD_LAST_MODIFIED_KEY, key).sendToTarget(); + } + + void put(String key, long sizeBytes) { + Preconditions.checkArgument(!TextUtils.isEmpty(key)); + SQLiteDatabase db = dbHelper.getWritableDatabase(); + SQLiteStatement insertStatement = statementPool.obtain(INSERT_NEW_KEY_SQL); + insertStatement.bindString(INSERT_NEW_KEY_KEY_IDX, key); + insertStatement.bindLong(INSERT_NEW_KEY_MODIFIED_TIME_IDX, clock.currentTimeMillis()); + insertStatement.bindLong(INSERT_NEW_KEY_SIZE_IDX, sizeBytes); + + SizeSQLiteTransactionListener sizeListener = sizeJournal.prepareSizeTransaction(); + db.beginTransactionWithListenerNonExclusive(sizeListener); + try { + insertStatement.executeInsert(); + sizeJournal.incrementSizeInTransaction(sizeListener, sizeBytes); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + statementPool.offer(INSERT_NEW_KEY_SQL, insertStatement); + sizeJournal.endSizeTransaction(sizeListener); + } + } + + List getPendingDeleteKeys() { + List result = new ArrayList<>(); + SQLiteDatabase db = dbHelper.getReadableDatabase(); + Cursor cursor = + db.query( + JournalTable.TABLE_NAME, + PENDING_DELETE_PROJECTION, + WHERE_PENDING_DELETE, + null /*selectionArgs*/, + null /*groupBy*/, + null /*having*/, + null /*orderBy*/); + try { + while (cursor.moveToNext()) { + String key = cursor.getString(cursor.getColumnIndexOrThrow(JournalTable.Columns.KEY)); + if (TextUtils.isEmpty(key)) { + if (LOG_WARN) { + Log.w(TAG, "Found empty or null key: %s, skipping delete: " + key); + } + } else { + result.add(key); + } + } + } finally { + cursor.close(); + } + return result; + } + + List getLeastRecentlyUsed(long targetByteCount) { + SQLiteDatabase db = dbHelper.getReadableDatabase(); + List keys = new ArrayList<>(); + long currentByteCount = 0; + int currentOffset = 0; + boolean isOutOfEntries = false; + while (!isOutOfEntries && currentByteCount < targetByteCount) { + Cursor cursor = + db.query( + JournalTable.TABLE_NAME, + LRU_PROJECTION, + LRU_WHERE, + null /*selectionArgs*/, + null /*groupBy*/, + null /*having*/, + LRU_ORDER_BY, + currentOffset + ", " + LRU_BATCH_SIZE); + try { + int keyIdx = cursor.getColumnIndexOrThrow(JournalTable.Columns.KEY); + int sizeIdx = cursor.getColumnIndexOrThrow(JournalTable.Columns.SIZE); + while (cursor.moveToNext() && currentByteCount < targetByteCount) { + String key = cursor.getString(keyIdx); + keys.add(key); + + long sizeBytes = cursor.getLong(sizeIdx); + currentByteCount += sizeBytes; + } + isOutOfEntries = cursor.getCount() < LRU_BATCH_SIZE; + } finally { + cursor.close(); + } + currentOffset += LRU_BATCH_SIZE; + } + + // TODO(judds): for a sufficiently large file or small cache size and a failed attempt to commit + // a put, this can happen because our journal size will temporarily not match our File size. + // If this becomes an issue, we can safely just clear the cache here instead of throwing because + // we were about to delete all the files anyway. + if (isOutOfEntries && currentByteCount < targetByteCount) { + throw new IllegalStateException( + "Size mismatch" + + ", expected to be able to evict at least " + + targetByteCount + + " bytes" + + ", but only found " + + currentByteCount + + " bytes worth of entries!"); + } + + return keys; + } + + List getStaleEntries(long staleTimeThresholdMs) { + SQLiteDatabase db = dbHelper.getReadableDatabase(); + List keys = new ArrayList<>(); + long currentRowId = 0L; + boolean isOutOfEntries = false; + while (!isOutOfEntries) { + try (Cursor cursor = + db.query( + JournalTable.TABLE_NAME, + STALE_PROJECTION, + LRU_WHERE + " AND " + STALE_WHERE, + new String[] {String.valueOf(currentRowId), String.valueOf(staleTimeThresholdMs)}, + null /*groupBy*/, + null /*having*/, + STALE_ORDER_BY, + String.valueOf(STALE_BATCH_SIZE))) { + int keyIdx = cursor.getColumnIndexOrThrow(JournalTable.Columns.KEY); + while (cursor.moveToNext()) { + keys.add(cursor.getString(keyIdx)); + currentRowId = cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)); + } + isOutOfEntries = cursor.getCount() < STALE_BATCH_SIZE; + } + } + return keys; + } + + /** + * Removes the pending entry from the journal for the given key and returns the size in bytes of + * the entry, or returns 0 if no such entry exists. + */ + void abortPut(String key) { + SQLiteDatabase db = dbHelper.getWritableDatabase(); + SQLiteStatement containsKeyStatement = statementPool.obtain(CONTAINS_KEY_SQL); + containsKeyStatement.bindString(CONTAINS_KEY_KEY_IDX, key); + SQLiteStatement selectNotPendingSizeStatement = + statementPool.obtain(SELECT_ENTRY_SIZE_NOT_PENDING_SQL); + selectNotPendingSizeStatement.bindString(SELECT_ENTRY_SIZE_NOT_PENDING_KEY_IDX, key); + SQLiteStatement deleteEntryStatement = statementPool.obtain(DELETE_ENTRY_SQL); + deleteEntryStatement.bindString(DELETE_ENTRY_KEY_IDX, key); + + SizeSQLiteTransactionListener sizeListener = sizeJournal.prepareSizeTransaction(); + db.beginTransactionWithListenerNonExclusive(sizeListener); + try { + // We may be asked to abort a put that failed before the entry was updated, so we will + // occasionally fail to find the entry here. + boolean isKeyPresent = 0 != containsKeyStatement.simpleQueryForLong(); + if (!isKeyPresent) { + return; + } + long entrySize; + try { + entrySize = selectNotPendingSizeStatement.simpleQueryForLong(); + } catch (SQLiteDoneException e) { + // No row found for this key that is not pending delete. + entrySize = 0; + } + int deleted = deleteEntryStatement.executeUpdateDelete(); + if (deleted != 1) { + throw new IllegalStateException( + "Failed to delete entry" + + ", key: " + + key + + ", size: " + + entrySize + + ", actually deleted: " + + deleted); + } else { + // If the item is pending delete its size is 0 here - skip decrementing. + if (entrySize != 0) { + sizeJournal.decrementSizeInTransaction(sizeListener, entrySize); + } + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + statementPool.offer(CONTAINS_KEY_SQL, containsKeyStatement); + statementPool.offer(SELECT_ENTRY_SIZE_NOT_PENDING_SQL, selectNotPendingSizeStatement); + statementPool.offer(DELETE_ENTRY_SQL, deleteEntryStatement); + sizeJournal.endSizeTransaction(sizeListener); + } + } + + void delete(List keys) { + SQLiteDatabase db = dbHelper.getWritableDatabase(); + for (int startPosition = 0; startPosition < keys.size(); startPosition += DELETE_BATCH_SIZE) { + int endPosition = Math.min(keys.size(), startPosition + DELETE_BATCH_SIZE); + List batch = keys.subList(startPosition, endPosition); + int batchSize = batch.size(); + if (batchSize == 0) { + if (LOG_WARN) { + Log.w( + TAG, + "Unexpectedly 0 sized batch between: " + + startPosition + + " and endPosition: " + + endPosition); + } + continue; + } + String[] keysToDelete = batch.toArray(new String[batchSize]); + db.beginTransactionNonExclusive(); + try { + int deleted = + db.delete(JournalTable.TABLE_NAME, buildKeySelectionSet(batchSize), keysToDelete); + if (deleted != keysToDelete.length && LOG_WARN) { + Log.w( + TAG, + "Failed to delete all expected entries" + + ", expected: " + + keysToDelete.length + + ", deleted: " + + deleted); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + } + + private static String buildKeySelectionSet(int count) { + Preconditions.checkArgument(count > 0); + String prefix = " IN("; + String postfix = "?)"; + // (?,?,?), so 2 characters per item, except for the last one which is one character. + int commaSeparatedCount = count - 1; + StringBuilder sb = + new StringBuilder( + JournalTable.Columns.KEY.length() + + prefix.length() + + (2 * commaSeparatedCount) + + postfix.length()) + .append(JournalTable.Columns.KEY) + .append(prefix); + for (int i = 0; i < commaSeparatedCount; i++) { + sb.append("?,"); + } + return sb.append(postfix).toString(); + } + + void markPendingDelete(List keys) { + ContentValues values = new ContentValues(); + values.put(JournalTable.Columns.PENDING_DELETE, 1); + SQLiteDatabase db = dbHelper.getWritableDatabase(); + + for (int startPosition = 0; startPosition < keys.size(); startPosition += DELETE_BATCH_SIZE) { + int endPosition = Math.min(keys.size(), startPosition + DELETE_BATCH_SIZE); + List batch = keys.subList(startPosition, endPosition); + int batchSize = batch.size(); + + String[] keysToDelete = batch.toArray(new String[batchSize]); + String keySelectionSet = buildKeySelectionSet(batchSize); + SizeSQLiteTransactionListener sizeListener = sizeJournal.prepareSizeTransaction(); + db.beginTransactionWithListenerNonExclusive(sizeListener); + try { + long sumOfSizesOfNewlyPendingEntries = + DatabaseUtils.longForQuery( + db, SUM_SIZE_WHERE_NOT_PENDING_DELETE + " AND " + keySelectionSet, keysToDelete); + sizeJournal.decrementSizeInTransaction(sizeListener, sumOfSizesOfNewlyPendingEntries); + db.update(JournalTable.TABLE_NAME, values, keySelectionSet, keysToDelete); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + sizeJournal.endSizeTransaction(sizeListener); + } + } + } + + private static class UpdateTimesCallback implements Handler.Callback { + private final SQLiteOpenHelper dbHelper; + private final int updateModifiedTimeBatchSize; + private final List keysToUpdate; + private final String batchUpdatedModifiedTimeSql; + private final Clock clock; + + private SQLiteStatement sqlStatement; + + UpdateTimesCallback(SQLiteOpenHelper dbHelper, int updateModifiedTimeBatchSize, Clock clock) { + this.clock = clock; + Preconditions.checkArgument(updateModifiedTimeBatchSize > 0); + this.dbHelper = dbHelper; + this.updateModifiedTimeBatchSize = updateModifiedTimeBatchSize; + keysToUpdate = new ArrayList<>(updateModifiedTimeBatchSize); + + batchUpdatedModifiedTimeSql = + "UPDATE " + + JournalTable.TABLE_NAME + + " SET " + + JournalTable.Columns.LAST_MODIFIED_TIME + + " = ?" + + " WHERE " + + buildKeySelectionSet(updateModifiedTimeBatchSize); + } + + private SQLiteStatement getSqlStatement() { + if (sqlStatement == null) { + sqlStatement = dbHelper.getWritableDatabase().compileStatement(batchUpdatedModifiedTimeSql); + } + return sqlStatement; + } + + private void updateTimes() { + long startTime = clock.currentTimeMillis(); + SQLiteDatabase db = dbHelper.getWritableDatabase(); + SQLiteStatement statement = getSqlStatement(); + long modifiedTime = clock.currentTimeMillis(); + statement.bindLong(1, modifiedTime); + int size = keysToUpdate.size(); + for (int i = 0; i < size; i++) { + String key = keysToUpdate.get(i); + // 1 indexed, with the modified time as the first argument. + statement.bindString(i + 2, key); + } + db.beginTransactionNonExclusive(); + try { + int updated = statement.executeUpdateDelete(); + if (updated != updateModifiedTimeBatchSize && LOG_DEBUG) { + Set uniqueKeys = new HashSet<>(keysToUpdate); + // This can happen in one of two cases: + // 1. Files are deleted out from under us (by the system), triggering a cache rebuild. + // 2. The corresponding entries are evicted while they're in the get queue. + Log.d( + TAG, + "Failed to update modified time for all rows" + + ", time: " + + modifiedTime + + ", expected: " + + updateModifiedTimeBatchSize + + ", actually updated: " + + updated + + ", unique keys: " + + uniqueKeys.size()); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + if (LOG_VERBOSE) { + Log.v( + TAG, + "Completed update times with " + + keysToUpdate.size() + + " updates in " + + (clock.currentTimeMillis() - startTime)); + } + } + + @Override + public boolean handleMessage(Message msg) { + if (msg.what != MessageIds.ADD_LAST_MODIFIED_KEY) { + return false; + } + String updatedKey = (String) msg.obj; + if (!keysToUpdate.contains(updatedKey)) { + keysToUpdate.add(updatedKey); + } + if (keysToUpdate.size() == updateModifiedTimeBatchSize) { + updateTimes(); + keysToUpdate.clear(); + } + + return true; + } + } +} diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/JournalTable.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/JournalTable.java new file mode 100644 index 0000000000..665e923182 --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/JournalTable.java @@ -0,0 +1,47 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +final class JournalTable { + static final String TABLE_NAME = "journal"; + private static final String INDEX_TIMESTAMP_KEY = "journal_timestamp_key_idx"; + + interface Columns { + /** The cache key/cache file name. */ + String KEY = "key"; + + /** The time the key was most recently created, updated, or read in UTC milliseconds. */ + String LAST_MODIFIED_TIME = "last_modified_time"; + + /** 1 if the key is going to be deleted, 0 otherwise. */ + String PENDING_DELETE = "pending_delete"; + + /** The length in bytes of the cache file. */ + String SIZE = "size"; + } + + static String getSqlCreateStatement() { + return "CREATE TABLE " + + TABLE_NAME + + " (" + + Columns.KEY + + " STRING PRIMARY KEY, " + + Columns.LAST_MODIFIED_TIME + + " INTEGER NOT NULL, " + + Columns.PENDING_DELETE + + " INTEGER NOT NULL DEFAULT 0, " + + Columns.SIZE + + " INTEGER NOT NULL" + + ")"; + } + + static String getIndexString() { + return "CREATE INDEX " + + INDEX_TIMESTAMP_KEY + + " ON " + + TABLE_NAME + + " (" + + Columns.LAST_MODIFIED_TIME + + ", " + + Columns.KEY + + ")"; + } +} diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/JournaledLruDiskCache.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/JournaledLruDiskCache.java new file mode 100644 index 0000000000..06bc531481 --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/JournaledLruDiskCache.java @@ -0,0 +1,469 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Process; +import android.util.Log; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.bumptech.glide.util.Preconditions; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * An Lru disk cache that stores each entry as a single File and uses a SQL based journal to track + * sizes and eviction order. + * + *

Operations are not guaranteed and will silently fail in unexpected cases. + * + *

The size of the cache is approximate and will be exceeded for short periods of time. Failure + * cases may leave behind temporary files that should be cleaned up in the future when the cache is + * re-opened or when operations are attempted again. + * + *

This class is thread safe and may be accessed from multiple threads simultaneously. + */ +final class JournaledLruDiskCache { + private static final String TAG = "DiskCache"; + private static final String CANARY_FILE_NAME = "cache_canary"; + // You must restart the app after enabling these logs for the change to take affect. + // We cache isLoggable to avoid the performance hit of checking repeatedly. + private static final boolean LOG_WARN = Log.isLoggable(TAG, Log.WARN); + private static final boolean LOG_VERBOSE = Log.isLoggable(TAG, Log.VERBOSE); + // The fraction of the maximum byte size of the cache we will allow the cache to go over before + // triggering an eviction. + private static final float DEFAULT_EVICTION_SLOP_MULTIPLIER = 0.05f; + // The number of items we will queue to update the date modified time of in batches. + private static final int DEFAULT_UPDATE_MODIFIED_TIME_BATCH_SIZE = 20; + + static final String TEMP_FILE_INDICATOR = ".tmp"; + + private final File cacheDirectory; + private final FileSystem fileSystem; + private final Journal journal; + // We use this File to determine if the system has wiped out our cache directory, which it may do + // at any time. If the File is not present, then either we've never opened the cache for the given + // directory before, or the cache was wiped. + private final File canaryFile; + private final EvictionManager evictionManager; + private final RecoveryManager recoveryManager; + private final EntryCache entries = new EntryCache(); + + private volatile boolean isOpen; + + /** + * @param cacheDirectory The directory in which the cache should store its files (Warning: the + * cache will delete all Files in the given directory. The directory should not be used to + * store any other content). + * @param maximumSizeBytes The target maximum size in bytes. The cache size may briefly exceed + * this size by up to around 25mb depending on the size, thread scheduling, and the number of + * failed requests. + */ + JournaledLruDiskCache( + File cacheDirectory, + DiskCacheDbHelper diskCacheDbHelper, + long maximumSizeBytes, + long staleEvictionThresholdMs, + Clock clock) { + this( + cacheDirectory, + diskCacheDbHelper, + new FileSystem() {}, + maximumSizeBytes, + getBackgroundLooper(), + DEFAULT_EVICTION_SLOP_MULTIPLIER, + DEFAULT_UPDATE_MODIFIED_TIME_BATCH_SIZE, + staleEvictionThresholdMs, + clock); + } + + @VisibleForTesting + JournaledLruDiskCache( + File cacheDirectory, + DiskCacheDbHelper diskCacheDbHelper, + FileSystem fileSystem, + long maximumSizeBytes, + Looper workLooper, + float slopMultiplier, + int updateModifiedTimeBatchSize, + long staleEvictionThresholdMs, + Clock clock) { + Preconditions.checkArgument( + updateModifiedTimeBatchSize >= 1, "updated modified time batch size must be >= 1"); + this.cacheDirectory = cacheDirectory; + this.fileSystem = fileSystem; + + journal = new Journal(diskCacheDbHelper, workLooper, updateModifiedTimeBatchSize, clock); + canaryFile = new File(cacheDirectory, CANARY_FILE_NAME); + + evictionManager = + new EvictionManager( + this, + cacheDirectory, + fileSystem, + journal, + workLooper, + maximumSizeBytes, + slopMultiplier, + staleEvictionThresholdMs, + clock); + recoveryManager = new RecoveryManager(this, cacheDirectory, journal, workLooper); + } + + private static Looper getBackgroundLooper() { + HandlerThread workThread = + new HandlerThread("disk_cache_journal", Process.THREAD_PRIORITY_BACKGROUND); + workThread.start(); + return workThread.getLooper(); + } + + @SuppressWarnings("checkstyle:UnnecessaryParentheses") // Readability + private void openIfNotOpen() { + if (!isOpen) { + synchronized (this) { + if (!isOpen) { + boolean createdDirectory = + cacheDirectory.mkdirs() || (cacheDirectory.exists() && cacheDirectory.isDirectory()); + if (!createdDirectory) { + throw new IllegalStateException("Failed to create cache directory: " + cacheDirectory); + } + journal.open(); + isOpen = true; + recoveryManager.triggerRecovery(); + } + } + } + } + + // TODO(judds): rather than polling, we should use Android's FileObserver. + private void verifyCanaryOrClear() { + if (fileSystem.exists(canaryFile)) { + return; + } + + synchronized (this) { + if (fileSystem.exists(canaryFile)) { + return; + } + if (LOG_WARN) { + Log.w(TAG, "Failed to find canary file, clearing disk cache"); + } + clear(); + } + } + + private void touchCanaryFile() { + try { + if (!fileSystem.createNewFile(canaryFile) && LOG_WARN) { + Log.w(TAG, "Failed to create new canary file"); + } + } catch (IOException e) { + if (LOG_WARN) { + Log.w(TAG, "Threw creating canary", e); + } + } + } + + long getCurrentSizeBytes() { + return journal.getCurrentSizeBytes(); + } + + /** + * Makes a best effort attempt to delete all Files and clear the journal. + * + *

In progress writes may still complete and/or leave behind partial data. + */ + public synchronized void clear() { + if (LOG_WARN) { + Log.w(TAG, "Clearing cache and deleting all entries!"); + } + fileSystem.deleteAll(cacheDirectory); + journal.clear(); + isOpen = false; + entries.clear(); + openIfNotOpen(); + touchCanaryFile(); + } + + /** + * Attempts to delete any content currently in the cache for the given key. + * + *

If no entry for the given key is found, this method will silently fail. If an entry is + * found, it is possible the File deletion will fail and be re-attempted in the future. + */ + public void delete(String key) { + delete(Collections.singletonList(key)); + } + + List delete(List keys) { + journal.markPendingDelete(keys); + List successfullyDeleted = new ArrayList<>(keys.size()); + for (String key : keys) { + EntryCache.Entry entry = entries.get(key); + entry.acquireWriteLock(); + try { + File file = getCacheFile(key); + if (fileSystem.delete(file)) { + successfullyDeleted.add(key); + } else if (LOG_WARN) { + Log.w(TAG, "Failed to delete file: " + file); + } + entry.setNotPresent(); + } finally { + entry.releaseWriteLock(); + } + } + journal.delete(successfullyDeleted); + return successfullyDeleted; + } + + /** + * Returns a File committed previously for the given key, or {@code null} if no such File exists. + * + *

If a write is in progress but not yet committed for the given key, this method will return + * {@code null} immediately, just as if the key were simply not present. + */ + public File get(String key) { + long startTime = getLogTime(); + openIfNotOpen(); + final File result; + EntryCache.Entry entry = entries.get(key); + entry.acquireReadLock(); + try { + if (entry.isStateKnown()) { + result = entry.isPresent() ? entry.getFile() : null; + } else { + File cacheFile = getCacheFile(key); + if (fileSystem.exists(cacheFile)) { + entry.setPresent(cacheFile); + result = cacheFile; + } else { + entry.setNotPresent(); + result = null; + } + } + if (result != null) { + journal.get(key); + } + + if (LOG_VERBOSE) { + Log.v(TAG, "Completed get in: " + getElapsedTime(startTime) + ", key: " + key); + } + } finally { + entry.releaseReadLock(); + } + + return result; + } + + /** + * Starts a put for the given key and returns a temporary {@link File} to which the caller can + * write data, or {@code null} if an edit is already in progress for the given Key, or if a + * committed entry already exists for the given key. + * + *

Callers should call {@link #commitPut(String, File)} with the given key and the {@link File} + * returned from this method after they finish writing data to make the data they have written + * available to calls to {@link #get(String)}. If an error occurs while writing data, callers can + * omit calling {@link #commitPut(String, File)} and use {@link #abortPutIfNotCommitted(String, + * File)} to cleanup any partial {@link File Files}. + * + *

Callers must call {@link #abortPutIfNotCommitted(String, File)} regardless of whether or not + * their write succeeds. The expected pattern is as follows: + * + *

{@code
+   * File tempFile = cache.beginPut(key);
+   * try {
+   *   if (tempFile != null && writeToFile(someData, tempFile)) {
+   *    cache.commitPut(key, tempFile);
+   *   }
+   * } finally {
+   *   cache.abortIfNotCommitted(key, tempFile);
+   * }
+   * }
+ * + *

Until the caller calls {@link #abortPutIfNotCommitted(String, File)}, a lock is held that + * will block future calls to this method for the given key. + * + *

The returned {@link File} may contain partial data if a previous write to this key failed. + * Callers should not assume it is safe to append to the File without first clearing it. + */ + @Nullable + public File beginPut(String key) { + long startTime = getLogTime(); + openIfNotOpen(); + verifyCanaryOrClear(); + EntryCache.Entry entry = entries.get(key); + entry.acquireWriteLock(); + + File permanentFile = getCacheFile(key); + if (fileSystem.exists(permanentFile)) { + return null; + } + + File result = getTempFile(key); + if (LOG_VERBOSE) { + Log.v(TAG, "Completed begin put in: " + getElapsedTime(startTime) + ", key: " + key); + } + return result; + } + + /** + * Updates the size of the cache based on the data in the given temporary file and renames the + * given temporary File to its permanent equivalent and makes it available to calls from {@link + * #get(String)}. + * + *

The given {@link File} must be a {@link File} returned from {@link #get(String)} for the + * given key. No validation is performed to verify either that the given {@link File} is a + * legitimate temporary file from this cache or that the given {@link File} matches the given key. + * + *

It is possible this commit may fail silently, there is no guarantee that the data in the + * given {@link File} will actually be available from {@link #get(String)}} when this method + * completes. In practice commits should fail rarely unless insufficient storage is available or + * the cache's directory or files are manipulated by a third party. + * + *

If the commit does fail, it will do so in one of two ways: + * + *

    + *
  • Prior to or while writing the entry to the journal + *
  • After writing the entry to the journal prior to or while renaming the temporary file to + * the permanent file. + *
+ * + * If the commit fails prior to writing the entry to the journal, the dangling temporary File will + * be found during recovery and deleted. If the commit fails after writing the entry to the + * journal, the temporary file will be found during recovery and deleted and the corresponding + * journal entry will also be deleted. The absence of a temporary File for a given key is assumed + * to mean that either no entry exists, or the entry is committed and may be read. + * + * @throws IllegalStateException If this method wasn't preceded by a call to {@link + * #beginPut(String)} for the given key. + */ + public void commitPut(String key, File temp) { + long startTime = getLogTime(); + + long totalBytesAdded = fileSystem.length(temp); + journal.put(key, totalBytesAdded); + + if (LOG_VERBOSE) { + Log.v(TAG, "Completed insertIntoDb in: " + getElapsedTime(startTime)); + } + + long startRenameTime = getLogTime(); + File permanentFile = getCacheFile(key); + + boolean isRenameSuccessful = fileSystem.rename(temp, permanentFile); + // If we fail to rename the file, we will try to recover in our next recovery phase. + if (isRenameSuccessful) { + if (LOG_VERBOSE) { + Log.v(TAG, "Successfully renamed in: " + getElapsedTime(startRenameTime)); + } + EntryCache.Entry entry = entries.get(key); + entry.setPresent(permanentFile); + } else if (LOG_WARN) { + Log.w(TAG, "Failed to rename file" + ", from: " + temp + ", to: " + permanentFile); + } + + evictionManager.maybeScheduleEviction(); + + if (LOG_VERBOSE) { + Log.v( + TAG, + "Completed commitPut in: " + + getElapsedTime(startTime) + + ", current size: " + + journal.getCurrentSizeBytes() + + ", key: " + + key); + } + } + + /** + * Releases the write lock for the given key and, if the write was not committed, cleans up the + * given temporary File and the corresponding journal entry for the given Key. + * + *

A write is assumed to have not been committed if the given temporary File still exists. + */ + public void abortPutIfNotCommitted(String key, File temp) { + try { + // If the temporary File still exists, we haven't committed. If it doesn't exist, we either + // didn't start writing and have nothing to roll back, or we finished writing and finished + // the rename so the edit is committed. + if (temp != null && fileSystem.delete(temp)) { + journal.abortPut(key); + EntryCache.Entry entry = entries.get(key); + entry.setUnknown(); + } + } finally { + EntryCache.Entry entry = entries.get(key); + entry.releaseWriteLock(); + } + } + + void recoverPartialWrite(File temp) { + String key = keyFromFile(temp); + EntryCache.Entry entry = entries.get(key); + entry.acquireWriteLock(); + try { + // Try to delete the temporary file, if it fails, we will try again in the next recovery + // phase. + boolean deleted = temp.delete(); + if (!deleted) { + if (LOG_WARN) { + Log.w(TAG, "Failed to cleanup in progress write: " + temp); + } + // The write lock prevents us from directly racing with an in progress write. However when + // the write lock is released, we will get to run. If the write completed successfully, + // the + // temp file will no longer exist, but the entry will. We do not want to delete the entry + // just because we happened to try to run recovery during the write. + return; + } + delete(key); + } finally { + entry.releaseWriteLock(); + } + } + + private String keyFromFile(File file) { + String name = file.getName(); + final String key; + if (name.endsWith(TEMP_FILE_INDICATOR)) { + key = name.substring(0, name.length() - TEMP_FILE_INDICATOR.length()); + } else { + key = name; + } + return key; + } + + /** + * Sets the maximum size of the cache to a new size in bytes. + * + *

Must be called on a background thread. + * + *

The EvictionManager manages the sizing of the cache. Decreasing the size may schedule an + * eviction if the current cache size exceeds newMaximumSizeBytes. Evictions will be scheduled and + * executed asynchronously. Therefore, the eviction will happen based on the latest maximum cache + * size, not the maximum size at scheduling. + */ + public void setMaximumSizeBytes(long newMaximumSizeBytes) { + evictionManager.setMaximumSizeBytes(newMaximumSizeBytes); + } + + private File getCacheFile(String key) { + return new File(cacheDirectory, key); + } + + private File getTempFile(String key) { + return new File(cacheDirectory, key + TEMP_FILE_INDICATOR); + } + + private static long getLogTime() { + return System.currentTimeMillis(); + } + + private static long getElapsedTime(long startTime) { + return getLogTime() - startTime; + } +} diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/MessageIds.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/MessageIds.java new file mode 100644 index 0000000000..5deacf447f --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/MessageIds.java @@ -0,0 +1,11 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +/** + * A unique set of non-zero message ids to use when requesting that work be done on the disk cache's + * background thread. + */ +interface MessageIds { + int ADD_LAST_MODIFIED_KEY = 1; + int EVICT = 2; + int RECOVER = 3; +} diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/RecoveryManager.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/RecoveryManager.java new file mode 100644 index 0000000000..c3dd0e9ca7 --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/RecoveryManager.java @@ -0,0 +1,74 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import java.io.File; +import java.io.FilenameFilter; +import java.util.List; + +/** Finds and cleans up failed writes and deletes on the work thread. */ +final class RecoveryManager { + + private final JournaledLruDiskCache diskCache; + private final Journal journal; + private final File diskCacheDir; + private final Looper workLooper; + private final Handler recoveryHandler; + + RecoveryManager( + JournaledLruDiskCache diskCache, File diskCacheDir, Journal journal, Looper workLooper) { + this.diskCache = diskCache; + this.journal = journal; + this.diskCacheDir = diskCacheDir; + this.workLooper = workLooper; + + recoveryHandler = new Handler(workLooper, new RecoveryCallback()); + } + + void triggerRecovery() { + recoveryHandler.obtainMessage(MessageIds.RECOVER).sendToTarget(); + } + + private void runRecoveryOnWorkThread() { + if (!Looper.myLooper().equals(workLooper)) { + throw new IllegalStateException( + "Cannot run recovery on a thread other than the work" + " thread!"); + } + recoverPartialWrites(); + recoverPartialDeletes(); + } + + private void recoverPartialDeletes() { + List pendingDeleteKeys = journal.getPendingDeleteKeys(); + diskCache.delete(pendingDeleteKeys); + } + + private void recoverPartialWrites() { + File[] partialWrites = + diskCacheDir.listFiles( + new FilenameFilter() { + @Override + public boolean accept(File dir, String filename) { + return filename.endsWith(JournaledLruDiskCache.TEMP_FILE_INDICATOR); + } + }); + if (partialWrites != null) { + for (File file : partialWrites) { + diskCache.recoverPartialWrite(file); + } + } + } + + private class RecoveryCallback implements Handler.Callback { + + @Override + public boolean handleMessage(Message msg) { + if (msg.what != MessageIds.RECOVER) { + return false; + } + runRecoveryOnWorkThread(); + return true; + } + } +} diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/SizeJournal.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/SizeJournal.java new file mode 100644 index 0000000000..a311099dce --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/SizeJournal.java @@ -0,0 +1,137 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +import android.content.ContentValues; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteStatement; +import android.database.sqlite.SQLiteTransactionListener; +import androidx.annotation.NonNull; +import androidx.core.util.Pools.Pool; +import com.bumptech.glide.util.pool.FactoryPools; +import com.bumptech.glide.util.pool.FactoryPools.Poolable; +import com.bumptech.glide.util.pool.FactoryPools.Resetter; +import com.bumptech.glide.util.pool.StateVerifier; +import java.util.concurrent.atomic.AtomicLong; + +final class SizeJournal { + private static final String UPDATE_CACHE_SIZE_SQL = + "UPDATE " + + SizeTable.TABLE_NAME + + " SET " + + SizeTable.Columns.SIZE + + " = " + + SizeTable.Columns.SIZE + + " + ?"; + private static final int UPDATE_CACHE_SIZE_SIZE_INCREMENT_IDX = 1; + + private static final String CONTAINS_SIZE_QUERY = "SELECT COUNT(*) FROM " + SizeTable.TABLE_NAME; + private static final String CACHE_SIZE_QUERY = + "SELECT " + SizeTable.Columns.SIZE + " FROM " + SizeTable.TABLE_NAME; + private final AtomicLong size = new AtomicLong(); + private final SqliteStatementPool updateCacheSizePool; + private final Pool sizeListenerPool = + FactoryPools.threadSafe( + /* size= */ 20, + new FactoryPools.Factory() { + @Override + public SizeSQLiteTransactionListener create() { + return new SizeSQLiteTransactionListener(); + } + }, + new Resetter() { + @Override + public void reset(@NonNull SizeSQLiteTransactionListener object) { + object.clear(); + } + }); + + private final DiskCacheDbHelper dbHelper; + + SizeJournal(DiskCacheDbHelper dbHelper) { + this.dbHelper = dbHelper; + updateCacheSizePool = new SqliteStatementPool(dbHelper); + } + + void open() { + SQLiteDatabase db = dbHelper.getReadableDatabase(); + boolean containsSize = + 0 != DatabaseUtils.longForQuery(db, CONTAINS_SIZE_QUERY, null /*selectionArgs*/); + final long currentSize; + if (!containsSize) { + ContentValues values = new ContentValues(); + values.put(SizeTable.Columns.SIZE, 0); + db.insert(SizeTable.TABLE_NAME, null /*nullColumnHack*/, values); + currentSize = 0; + } else { + currentSize = DatabaseUtils.longForQuery(db, CACHE_SIZE_QUERY, null /*selectionArgs*/); + } + size.set(currentSize); + } + + void clearInTransaction() { + SQLiteDatabase db = dbHelper.getReadableDatabase(); + db.delete(SizeTable.TABLE_NAME, null /*whereClause*/, null /*whereArgs*/); + size.set(0); + } + + long getCacheSizeBytes() { + return size.get(); + } + + SizeSQLiteTransactionListener prepareSizeTransaction() { + SizeSQLiteTransactionListener result = sizeListenerPool.acquire(); + if (result == null) { + result = new SizeSQLiteTransactionListener(); + } + return result; + } + + void endSizeTransaction(SizeSQLiteTransactionListener listener) { + listener.clear(); + sizeListenerPool.release(listener); + } + + void decrementSizeInTransaction(SizeSQLiteTransactionListener sizeListener, long decrementBy) { + incrementSizeInTransaction(sizeListener, -decrementBy); + } + + void incrementSizeInTransaction(SizeSQLiteTransactionListener sizeListener, long incrementBy) { + sizeListener.updatedSize = incrementBy; + + SQLiteStatement updateCacheSizeStatement = updateCacheSizePool.obtain(UPDATE_CACHE_SIZE_SQL); + try { + updateCacheSizeStatement.bindLong(UPDATE_CACHE_SIZE_SIZE_INCREMENT_IDX, incrementBy); + updateCacheSizeStatement.executeUpdateDelete(); + size.addAndGet(incrementBy); + } finally { + updateCacheSizePool.offer(UPDATE_CACHE_SIZE_SQL, updateCacheSizeStatement); + } + } + + /** A listener that reverts size changes upon transaction failure. */ + final class SizeSQLiteTransactionListener implements SQLiteTransactionListener, Poolable { + private long updatedSize; + + void clear() { + updatedSize = 0; + } + + @Override + public void onBegin() {} + + @Override + public void onCommit() {} + + @Override + public void onRollback() { + // Revert the increment of size on transaction failure. + size.addAndGet(-updatedSize); + } + + @NonNull + @Override + public StateVerifier getVerifier() { + return StateVerifier.newInstance(); + } + } +} diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/SizeTable.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/SizeTable.java new file mode 100644 index 0000000000..8aa20eb5cb --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/SizeTable.java @@ -0,0 +1,23 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +final class SizeTable { + static final String TABLE_NAME = "size"; + + interface Columns { + String ID = "id"; + + /** The total size in bytes of all files in the cache (+- pending deletes and inserts). */ + String SIZE = "size"; + } + + static String getSqlCreateStatement() { + return "CREATE TABLE " + + TABLE_NAME + + " (" + + Columns.ID + + " INTEGER PRIMARY KEY, " + + Columns.SIZE + + " INTEGER NOT NULL DEFAULT 0" + + ")"; + } +} diff --git a/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/SqliteStatementPool.java b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/SqliteStatementPool.java new file mode 100644 index 0000000000..dbaae59f5c --- /dev/null +++ b/integration/sqljournaldiskcache/src/main/java/com/bumptech/glide/integration/sqljournaldiskcache/SqliteStatementPool.java @@ -0,0 +1,47 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteStatement; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.Map; +import java.util.Queue; + +final class SqliteStatementPool { + private static final int MAX_SIZE = 10; + + private final Map> pool = new HashMap<>(); + private final SQLiteOpenHelper dbHelper; + + SqliteStatementPool(SQLiteOpenHelper dbHelper) { + this.dbHelper = dbHelper; + } + + SQLiteStatement obtain(String sql) { + SQLiteStatement statement = null; + synchronized (pool) { + Queue queueForSql = pool.get(sql); + if (queueForSql != null) { + statement = queueForSql.poll(); + } + } + if (statement == null) { + statement = dbHelper.getWritableDatabase().compileStatement(sql); + } + return statement; + } + + void offer(String sql, SQLiteStatement statement) { + statement.clearBindings(); + synchronized (pool) { + Queue queueForSql = pool.get(sql); + if (queueForSql == null) { + queueForSql = new ArrayDeque<>(MAX_SIZE); + pool.put(sql, queueForSql); + } + if (queueForSql.size() < MAX_SIZE) { + queueForSql.offer(statement); + } + } + } +} diff --git a/integration/sqljournaldiskcache/src/test/java/com/bumptech/glide/integration/sqljournaldiskcache/DiskCacheDbHelperUpgradeTest.java b/integration/sqljournaldiskcache/src/test/java/com/bumptech/glide/integration/sqljournaldiskcache/DiskCacheDbHelperUpgradeTest.java new file mode 100644 index 0000000000..bb818f0218 --- /dev/null +++ b/integration/sqljournaldiskcache/src/test/java/com/bumptech/glide/integration/sqljournaldiskcache/DiskCacheDbHelperUpgradeTest.java @@ -0,0 +1,60 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class DiskCacheDbHelperUpgradeTest { + private final Context context = ApplicationProvider.getApplicationContext(); + + @Test + public void onUpgrade_fromVersionOneToTwo_producesFunctionalTablesAndColumns() + throws IOException { + try (DiskCacheDbHelper versionOneHelper = + new DiskCacheDbHelper(context, /* isInMemory= */ false, /* databaseVersion= */ 1)) { + versionOneHelper.getWritableDatabase(); + } + + try (DiskCacheDbHelper versionTwoHelper = + new DiskCacheDbHelper(context, /* isInMemory= */ false, /* databaseVersion= */ 2)) { + versionTwoHelper.getWritableDatabase(); + } + + ensureWeCanReadFromDiskCache(); + } + + // A poor mans way of ensuring that we can read from the various sqlite tables in the way we + // expect. + private void ensureWeCanReadFromDiskCache() throws IOException { + try (DiskCacheDbHelper diskCacheDbHelper = DiskCacheDbHelper.forProd(context)) { + JournaledLruDiskCache diskCache = + new JournaledLruDiskCache( + context.getCacheDir(), + diskCacheDbHelper, + /* maximumSizeBytes= */ Long.MAX_VALUE, + /* staleEvictionThresholdMs= */ Long.MAX_VALUE, + new DefaultClock()); + + String key = "key"; + File file = diskCache.beginPut(key); + try { + try (FileOutputStream os = new FileOutputStream(file)) { + os.write(1); + } + diskCache.commitPut(key, file); + } finally { + diskCache.abortPutIfNotCommitted(key, file); + } + + assertThat(diskCache.get(key)).isNotNull(); + } + } +} diff --git a/integration/sqljournaldiskcache/src/test/java/com/bumptech/glide/integration/sqljournaldiskcache/DiskCacheUtils.java b/integration/sqljournaldiskcache/src/test/java/com/bumptech/glide/integration/sqljournaldiskcache/DiskCacheUtils.java new file mode 100644 index 0000000000..83b74f2890 --- /dev/null +++ b/integration/sqljournaldiskcache/src/test/java/com/bumptech/glide/integration/sqljournaldiskcache/DiskCacheUtils.java @@ -0,0 +1,87 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +import androidx.test.core.app.ApplicationProvider; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import org.junit.rules.ExternalResource; + +final class DiskCacheUtils { + + private DiskCacheUtils() {} + + static void writeToFile(File file, String data) { + byte[] bytes = data.getBytes(); + writeToFile(file, bytes); + } + + static void writeToFile(File file, byte[] bytes) { + try (OutputStream os = new FileOutputStream(file)) { + os.write(bytes); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + static byte[] readFromFile(File file) { + byte[] result = new byte[(int) file.length()]; + + try (FileInputStream is = new FileInputStream(file)) { + int readSoFar = 0; + int read; + while ((read = is.read(result, readSoFar, result.length - readSoFar)) != -1 + && readSoFar < result.length) { + readSoFar += read; + } + if (readSoFar != result.length) { + throw new IllegalStateException("Failed to read all data from: " + file); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + return result; + } + + private static void deleteRecursively(File file) { + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File f : children) { + deleteRecursively(f); + } + } + } else { + if (!file.delete() && file.exists()) { + throw new IllegalStateException("Failed to delete; " + file); + } + } + } + + static final class DiskCacheDirRule extends ExternalResource { + + private File cacheDir; + + @Override + protected void before() throws Throwable { + cacheDir = + new File(ApplicationProvider.getApplicationContext().getCacheDir(), "test_sql_cache"); + super.before(); + } + + @Override + protected void after() { + super.after(); + deleteRecursively(cacheDir); + } + + void cleanup() { + deleteRecursively(cacheDir); + } + + File diskCacheDir() { + return cacheDir; + } + } +} diff --git a/integration/sqljournaldiskcache/src/test/java/com/bumptech/glide/integration/sqljournaldiskcache/JournaledLruDiskCacheTest.java b/integration/sqljournaldiskcache/src/test/java/com/bumptech/glide/integration/sqljournaldiskcache/JournaledLruDiskCacheTest.java new file mode 100644 index 0000000000..1406df814e --- /dev/null +++ b/integration/sqljournaldiskcache/src/test/java/com/bumptech/glide/integration/sqljournaldiskcache/JournaledLruDiskCacheTest.java @@ -0,0 +1,874 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import android.content.Context; +import android.os.Looper; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.bumptech.glide.integration.sqljournaldiskcache.DiskCacheUtils.DiskCacheDirRule; +import com.bumptech.glide.util.Preconditions; +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import java.util.Collections; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class JournaledLruDiskCacheTest { + private final Context context = ApplicationProvider.getApplicationContext(); + + @Rule public final DiskCacheDirRule diskCacheDirRule = new DiskCacheDirRule(); + + private final TestClock testClock = new TestClock(); + private JournaledLruDiskCache cache; + private int size; + private FileSystem fileSystem; + private DiskCacheDbHelper dbHelper; + private File cacheDir; + + @Before + public void setUp() { + dbHelper = DiskCacheDbHelper.forTesting(context); + + cacheDir = diskCacheDirRule.diskCacheDir(); + fileSystem = spy(new FileSystem() {}); + size = 1024; + cache = newCache(); + } + + private JournaledLruDiskCache newCache() { + return newCache(/* evictionSlopMultiplier= */ 0f); + } + + private JournaledLruDiskCache newCache(float evictionSlopMultiplier) { + return new JournaledLruDiskCache( + cacheDir, + dbHelper, + fileSystem, + size, + Looper.getMainLooper(), + evictionSlopMultiplier, + /* updateModifiedTimeBatchSize= */ 1, + /* staleEvictionThresholdMs= */ Long.MAX_VALUE, + testClock::currentTimeMillis); + } + + @After + public void tearDown() { + dbHelper.close(); + } + + @Test + public void beginPut_createsCanaryFile() { + cache.beginPut("key"); + assertThat(cacheDir.listFiles()).hasLength(1); + } + + @Test + public void beginPut_withExistingFileForKey_returnsNull() { + String key = "key"; + File file = cache.beginPut(key); + try { + DiskCacheUtils.writeToFile(file, "data"); + cache.commitPut(key, file); + } finally { + cache.abortPutIfNotCommitted(key, file); + } + File secondPutFile = cache.beginPut(key); + assertThat(secondPutFile).isNull(); + } + + @Test + public void commitPut_withFailedPreviousWrite_leavesSizeConsistent() { + String key = "key"; + + File temp = cache.beginPut(key); + try { + when(fileSystem.rename(temp, new File(cacheDir, key))).thenReturn(false).thenCallRealMethod(); + // Write a file so large it should get evicted immediately + byte[] bytes = new byte[size * 2]; + DiskCacheUtils.writeToFile(temp, bytes); + cache.commitPut(key, temp); + } finally { + cache.abortPutIfNotCommitted(key, temp); + } + + // Verify file was evicted and size is 0 since the big file was evicted. + assertThat(getSize(cacheDir)).isEqualTo(0); + assertThat(cache.getCurrentSizeBytes()).isEqualTo(0); + } + + @Test + public void commitPut_withFailedPreviousWrite_replacesContent() { + String key = "key"; + + File temp = cache.beginPut(key); + // This is a spy, rename below actually performs the rename (which just renames nothing to + // nothing in this case), it must come before writeToFile or commitPut. + when(fileSystem.rename(temp, new File(cacheDir, key))).thenReturn(false).thenCallRealMethod(); + DiskCacheUtils.writeToFile(temp, "first data"); + cache.commitPut(key, temp); + + // If the app crashes prior to abortIfNotCommitted: + cache = newCache(); + + String expectedData = "second data"; + temp = cache.beginPut(key); + try { + DiskCacheUtils.writeToFile(temp, expectedData); + cache.commitPut(key, temp); + } finally { + cache.abortPutIfNotCommitted(key, temp); + } + + assertThat(cache.get(key)).isNotNull(); + assertThat(readFromFile(cache.get(key))).isEqualTo(expectedData); + } + + @Test + public void testAbortPutIfNotCommitted_handlesNullFiles() { + String key = "key"; + cache.beginPut(key); + cache.abortPutIfNotCommitted(key, null); + } + + @Test + public void abortPutIfNotCommitted_decrementsSizeIfRenameToFails() { + // Write a large File and then fail to rename it so the journal size temporarily doesn't match + // the file system size. The slop multiplier will then cause the cache to calculate an amount + // to delete that is more than the number of Files available, unless we've properly accounted + // for the rename failure. + cache = newCache(/* evictionSlopMultiplier= */ 0.5f); + + String largeKey = "large"; + File file = cache.beginPut(largeKey); + try { + // This is a spy, rename below actually performs the rename (which just renames nothing to + // nothing in this case), it must come before writeToFile or commitPut. + when(fileSystem.rename(file, new File(cacheDir, largeKey))).thenReturn(false); + byte[] bytes = new byte[size - 1]; + DiskCacheUtils.writeToFile(file, bytes); + cache.commitPut(largeKey, file); + } finally { + cache.abortPutIfNotCommitted(largeKey, file); + } + + String smallKey = "key"; + int totalSmallFiles = 2; + for (int i = 0; i < totalSmallFiles; i++) { + String key = smallKey + i; + File smallFile = cache.beginPut(key); + try { + byte[] bytes = new byte[(size / totalSmallFiles) - 1]; + DiskCacheUtils.writeToFile(smallFile, bytes); + cache.commitPut(key, smallFile); + } finally { + cache.abortPutIfNotCommitted(key, smallFile); + } + } + + for (int i = 0; i < totalSmallFiles; i++) { + assertThat(cache.get(smallKey + i)).isNotNull(); + } + } + + @Test + public void abortPutIfNotCommitted_decrementsSizeInJournalIfRenameToFails() { + cache = newCache(/* evictionSlopMultiplier= */ 0.5f); + + String largeKey = "large"; + File file = cache.beginPut(largeKey); + try { + // This is a spy, rename below actually performs the rename (which just renames nothing to + // nothing in this case), it must come before writeToFile or commitPut. + when(fileSystem.rename(file, new File(cacheDir, largeKey))).thenReturn(false); + byte[] bytes = new byte[size - 1]; + DiskCacheUtils.writeToFile(file, bytes); + cache.commitPut(largeKey, file); + } finally { + cache.abortPutIfNotCommitted(largeKey, file); + } + + // Re-open the cache. + cache = newCache(/* evictionSlopMultiplier= */ 0.5f); + + String smallKey = "key"; + int totalSmallFiles = 2; + for (int i = 0; i < totalSmallFiles; i++) { + String key = smallKey + i; + File smallFile = cache.beginPut(key); + try { + byte[] bytes = new byte[(size / totalSmallFiles) - 1]; + DiskCacheUtils.writeToFile(smallFile, bytes); + cache.commitPut(key, smallFile); + } finally { + cache.abortPutIfNotCommitted(key, smallFile); + } + } + + for (int i = 0; i < totalSmallFiles; i++) { + assertThat(cache.get(smallKey + i)).isNotNull(); + } + } + + @Test(expected = IllegalMonitorStateException.class) + public void testAbortPutIfNotCommitted_withoutBeginPut_throws() { + cache.abortPutIfNotCommitted("fakeKey", new File(cacheDir, "fakeFile")); + } + + @Test + public void get_afterCommittedPut_returnsFileWithData() { + String key = "myKey"; + String data = "data"; + + File toPut = cache.beginPut(key); + try { + DiskCacheUtils.writeToFile(toPut, data); + cache.commitPut(key, toPut); + } finally { + cache.abortPutIfNotCommitted(key, toPut); + } + + File fromGet = cache.get(key); + + assertThat(readFromFile(fromGet)).isEqualTo(data); + } + + @Test + public void get_beforePut_returnsNull() { + assertThat(cache.get("key")).isNull(); + } + + @Test + public void get_afterAbortedPut_returnsNull() { + String key = "key"; + File toPut = cache.beginPut(key); + try { + String data = "data"; + DiskCacheUtils.writeToFile(toPut, data); + } finally { + cache.abortPutIfNotCommitted(key, toPut); + } + + File fromGet = cache.get(key); + assertThat(fromGet).isNull(); + } + + @Test + public void abortPutIfNotCommitted_whenNotCommitted_discardsData() { + String key = "key"; + File toPut = cache.beginPut(key); + try { + String data = "data"; + DiskCacheUtils.writeToFile(toPut, data); + } finally { + cache.abortPutIfNotCommitted(key, toPut); + } + + assertThat(cacheDir.listFiles()).hasLength(1); + assertThat(getSize(cacheDir)).isEqualTo(0L); + } + + @Test + public void commitPut_runsEvictionIfNecessary() { + int totalFiles = 5; + byte[] data = new byte[size / 3]; + for (int i = 0; i < totalFiles; i++) { + String key = "key" + i; + File file = cache.beginPut(key); + try { + DiskCacheUtils.writeToFile(file, data); + cache.commitPut(key, file); + } finally { + cache.abortPutIfNotCommitted(key, file); + } + } + + onIdleWorkerThread(); + assertThat(getSize(cacheDir)).isLessThan((long) size); + } + + @Test + public void eviction_removesFirstPutFile() { + int totalFiles = 3; + byte[] data = new byte[(size / totalFiles) + 1]; + String keyBase = "key"; + for (int i = 0; i < totalFiles; i++) { + String key = keyBase + i; + File file = cache.beginPut(key); + try { + DiskCacheUtils.writeToFile(file, data); + cache.commitPut(key, file); + } finally { + cache.abortPutIfNotCommitted(key, file); + } + testClock.advance(Duration.ofMillis(1)); + } + + onIdleWorkerThread(); + + assertThat(cache.get(keyBase + 0)).isNull(); + assertThat(cache.get(keyBase + 1)).isNotNull(); + assertThat(cache.get(keyBase + 2)).isNotNull(); + } + + // Eviction is triggered by posts. + private static void onIdleWorkerThread() { + shadowOf(Looper.getMainLooper()).idle(); + } + + @Test + public void eviction_withGets_removesLeastRecentlyUsedFile() { + int totalFiles = 3; + byte[] data = new byte[(size / totalFiles) + 1]; + String keyBase = "key"; + for (int i = 0; i < totalFiles; i++) { + String key = keyBase + i; + File file = cache.beginPut(key); + try { + DiskCacheUtils.writeToFile(file, data); + cache.commitPut(key, file); + } finally { + cache.abortPutIfNotCommitted(key, file); + } + + if (i == 1) { + testClock.advance(Duration.ofMillis(1)); + cache.get(keyBase + 0); + } + testClock.advance(Duration.ofMillis(1)); + } + + onIdleWorkerThread(); + + assertThat(cache.get(keyBase + 0)).isNotNull(); + assertThat(cache.get(keyBase + 1)).isNull(); + assertThat(cache.get(keyBase + 2)).isNotNull(); + } + + @Test + public void eviction_withManyEntries_updatesSizeCorrectly() { + int numSmallFiles = 3; + byte[] largeData = new byte[size - 1]; + byte[] smallData = new byte[(size / numSmallFiles) - 1]; + String largeKey = "largeKey"; + + for (int i = 0; i < 2; i++) { + String key = largeKey + i; + File largeFile = cache.beginPut(key); + try { + DiskCacheUtils.writeToFile(largeFile, largeData); + cache.commitPut(key, largeFile); + } finally { + cache.abortPutIfNotCommitted(key, largeFile); + } + testClock.advance(Duration.ofMillis(1)); + } + + String smallkey = "smallKey"; + for (int i = 0; i < numSmallFiles; i++) { + String key = smallkey + i; + File file = cache.beginPut(key); + try { + DiskCacheUtils.writeToFile(file, smallData); + cache.commitPut(key, file); + } finally { + cache.abortPutIfNotCommitted(key, file); + } + testClock.advance(Duration.ofMillis(1)); + } + + onIdleWorkerThread(); + + for (int i = 0; i < numSmallFiles; i++) { + assertThat(cache.get(smallkey + i)).isNotNull(); + } + } + + // The goal here is to ensure our sql batching works as expected. We aim for more than 999 files + // because sql only allows 999 arguments for a single query. + @Test + public void eviction_writeManyFiles_evictsManyEntries() throws IOException { + String smallKey = "small"; + for (int i = 0; i < 1000; i++) { + String key = smallKey + i; + File file = cache.beginPut(key); + try { + if (!file.createNewFile()) { + throw new IllegalStateException("Failed to create: " + file); + } + cache.commitPut(key, file); + } finally { + cache.abortPutIfNotCommitted(key, file); + } + testClock.advance(Duration.ofMillis(1)); + } + + String largeKey = "large"; + File largeFile = cache.beginPut(largeKey); + try { + byte[] bytes = new byte[size + 1]; + DiskCacheUtils.writeToFile(largeFile, bytes); + cache.commitPut(largeKey, largeFile); + } finally { + cache.abortPutIfNotCommitted(largeKey, largeFile); + } + + onIdleWorkerThread(); + + assertThat(cacheDir.listFiles()).hasLength(1); + } + + @Test + public void delete_missingFile_ignored() { + cache.delete("fakeKey"); + } + + @Test + public void delete_removesEntryForKey() { + String key = "key"; + File temp = cache.beginPut(key); + try { + DiskCacheUtils.writeToFile(temp, "data"); + cache.commitPut(key, temp); + } finally { + cache.abortPutIfNotCommitted(key, temp); + } + + assertThat(cache.get(key)).isNotNull(); + assertThat(cacheDir.listFiles()).hasLength(2); + + cache.delete(key); + + assertThat(cache.get(key)).isNull(); + assertThat(cacheDir.listFiles()).hasLength(1); + } + + @Test + public void delete_withInProgressWriteForKey_doesNotDeleteKey() { + String key = "key"; + File file = new File(cacheDir, key); + when(fileSystem.delete(file)).thenReturn(false); + when(fileSystem.exists(file)).thenReturn(false).thenReturn(true); + + assertThat(cache.delete(Collections.singletonList(key))).isEmpty(); + } + + @Test + public void delete_onPreviouslyFailedKey_doesNotDecrementCacheSizeTwice() { + String key = "key"; + File file = new File(cacheDir, key); + // first delete attempt, second delete attempt. + when(fileSystem.delete(file)).thenReturn(false).thenCallRealMethod(); + + File temp = cache.beginPut(key); + try { + byte[] bytes = new byte[size - 1]; + DiskCacheUtils.writeToFile(temp, bytes); + cache.commitPut(key, temp); + } finally { + cache.abortPutIfNotCommitted(key, temp); + } + + cache.delete(key); + cache.delete(key); + + // We should have successfully deleted the file. + assertThat(cacheDir.listFiles()).hasLength(1); + + String otherKey = "other"; + for (int i = 0; i < 2; i++) { + String currentKey = otherKey + i; + temp = cache.beginPut(currentKey); + try { + byte[] bytes = new byte[size - 1]; + DiskCacheUtils.writeToFile(temp, bytes); + cache.commitPut(currentKey, temp); + } finally { + cache.abortPutIfNotCommitted(currentKey, file); + } + } + + onIdleWorkerThread(); + // Only one File should remain. Two will if we double counted the delete of the single key. + assertThat(cacheDir.listFiles()).hasLength(2); + } + + @Test + public void clear_removesAllEntriesAndFiles() { + String firstKey = "key1"; + File temp = cache.beginPut(firstKey); + try { + DiskCacheUtils.writeToFile(temp, "data1"); + cache.commitPut(firstKey, temp); + } finally { + cache.abortPutIfNotCommitted(firstKey, temp); + } + testClock.advance(Duration.ofMillis(1)); + + String secondKey = "key2"; + temp = cache.beginPut(secondKey); + try { + DiskCacheUtils.writeToFile(temp, secondKey); + cache.commitPut(secondKey, temp); + } finally { + cache.abortPutIfNotCommitted(secondKey, temp); + } + + assertThat(cache.get(firstKey)).isNotNull(); + assertThat(cache.get(secondKey)).isNotNull(); + assertThat(cacheDir.listFiles()).hasLength(3); + + cache.clear(); + + assertThat(cache.get(firstKey)).isNull(); + assertThat(cache.get(secondKey)).isNull(); + // Now it should just contain the canary. + assertThat(cacheDir.listFiles()).hasLength(1); + } + + @Test + public void recovery_withPartialWriteAndJournalEntry_deletesTempFileAndDecrementsSize() { + String successKey = "success"; + File successTemp = cache.beginPut(successKey); + try { + byte[] bytes = new byte[size / 2]; + DiskCacheUtils.writeToFile(successTemp, bytes); + cache.commitPut(successKey, successTemp); + } finally { + cache.abortPutIfNotCommitted(successKey, successTemp); + } + onIdleWorkerThread(); + Preconditions.checkNotNull(cache.get(successKey)); + + String failKey = "fail"; + File failPermanent = new File(cacheDir, failKey); + File failTemp = cache.beginPut(failKey); + when(fileSystem.rename(failTemp, failPermanent)).thenReturn(false).thenCallRealMethod(); + + // Simulate a crash by failing to calll abortPutIfNotCommitted. + byte[] bytes1 = new byte[(size / 2) - 1]; + DiskCacheUtils.writeToFile(failTemp, bytes1); + cache.commitPut(failKey, failTemp); + + // We should have the success permanent file, the failed temp file, and the canary file. + assertThat(cacheDir.listFiles()).hasLength(3); + + // Re-open the cache. + cache = newCache(); + + String secondSuccessKey = "secondSuccess"; + File secondSuccessTemp = cache.beginPut(secondSuccessKey); + try { + byte[] bytes = new byte[(size / 2) - 1]; + DiskCacheUtils.writeToFile(secondSuccessTemp, bytes); + cache.commitPut(secondSuccessKey, secondSuccessTemp); + } finally { + cache.abortPutIfNotCommitted(secondSuccessKey, secondSuccessTemp); + } + + onIdleWorkerThread(); + assertThat(cache.get(successKey)).isNotNull(); + assertThat(cache.get(failKey)).isNull(); + assertThat(cache.get(secondSuccessKey)).isNotNull(); + } + + @Test + public void recovery_withPartialWriteAndNoJournalEntry_deletesTempFile() { + String partialWriteKey = "partialWriteKey"; + File partialWriteTemp = cache.beginPut(partialWriteKey); + byte[] bytes1 = new byte[size]; + DiskCacheUtils.writeToFile(partialWriteTemp, bytes1); + + cache = newCache(); + + // Verify we haven't done unexpected things to the cache size. + String baseKey = "key"; + for (int i = 0; i < 4; i++) { + String key = baseKey + i; + File temp = cache.beginPut(key); + try { + byte[] bytes = new byte[(size / 4) + 1]; + DiskCacheUtils.writeToFile(temp, bytes); + cache.commitPut(key, temp); + } finally { + cache.abortPutIfNotCommitted(key, temp); + } + testClock.advance(Duration.ofMillis(1)); + } + + onIdleWorkerThread(); + + // Canary + 3 smaller files. + assertThat(cacheDir.listFiles()).hasLength(4); + + for (int i = 0; i < 4; i++) { + String key = baseKey + i; + if (i == 0) { + assertThat(cache.get(key)).isNull(); + } else { + assertThat(cache.get(key)).isNotNull(); + } + } + } + + @Test + public void recovery_withPendingDeleteForFile_deletesFileAndEntry() { + String key = "key"; + File permanentFile = new File(cacheDir, key); + when(fileSystem.delete(permanentFile)).thenReturn(false).thenCallRealMethod(); + + File temp = cache.beginPut(key); + try { + DiskCacheUtils.writeToFile(temp, "data"); + cache.commitPut(key, temp); + } finally { + cache.abortPutIfNotCommitted(key, temp); + } + + // Failed delete. + cache.delete(key); + + // Failed delete + canary. + assertThat(cacheDir.listFiles()).hasLength(2); + + cache = newCache(); + + String otherKey = "other"; + temp = cache.beginPut(otherKey); + try { + DiskCacheUtils.writeToFile(temp, "otherData"); + cache.commitPut(otherKey, temp); + } finally { + cache.abortPutIfNotCommitted(otherKey, temp); + } + + onIdleWorkerThread(); + assertThat(cache.get(key)).isNull(); + // Canary + second key. + assertThat(cacheDir.listFiles()).hasLength(2); + } + + @Test + public void recovery_withInProgressWrite_doesNotDeleteFile() { + String key = "key"; + String data = "data"; + File temp = cache.beginPut(key); + try { + DiskCacheUtils.writeToFile(temp, data); + cache.commitPut(key, temp); + } finally { + cache.abortPutIfNotCommitted(key, temp); + } + // Simulate a concurrent recovery attempt now obtaining the write lock. + cache.recoverPartialWrite(temp); + // Make sure that it doesn't delete the fully written file + File cacheFile = cache.get(key); + assertThat(cacheFile).isNotNull(); + assertThat(readFromFile(cacheFile)).isEqualTo(data); + } + + @Test + public void setMaximumSizeBytes_increaseCacheSize_doesNotEvictEntries() { + String key = "key"; + File toPut = cache.beginPut(key); + + cache.setMaximumSizeBytes(size * 3); + try { + // write a file that exceeds the old maximum + byte[] bytes = new byte[size * 3]; + DiskCacheUtils.writeToFile(toPut, bytes); + cache.commitPut(key, toPut); + } finally { + cache.abortPutIfNotCommitted(key, toPut); + } + + assertThat(getSize(cacheDir)).isEqualTo(size * 3); + assertThat(cache.getCurrentSizeBytes()).isEqualTo(size * 3); + } + + @Test + public void setMaximumSizeBytes_increaseCacheSize_evictEntries() { + String key = "key"; + File toPut = cache.beginPut(key); + + cache.setMaximumSizeBytes(size * 2); + try { + // write a file that exceeds the new max + byte[] bytes = new byte[size * 3]; + DiskCacheUtils.writeToFile(toPut, bytes); + cache.commitPut(key, toPut); + } finally { + cache.abortPutIfNotCommitted(key, toPut); + } + + onIdleWorkerThread(); + assertThat(getSize(cacheDir)).isEqualTo(0); + assertThat(cache.getCurrentSizeBytes()).isEqualTo(0); + } + + @Test + public void setMaximumSizeBytes_decreaseCacheSize_doesNotEvictEntries() { + String key = "key"; + File toPut = cache.beginPut(key); + int tinySize = 20; + try { + // write a file that satisfies original and new cache space + byte[] bytes = new byte[tinySize]; + DiskCacheUtils.writeToFile(toPut, bytes); + cache.commitPut(key, toPut); + } finally { + cache.abortPutIfNotCommitted(key, toPut); + } + + onIdleWorkerThread(); + assertThat(getSize(cacheDir)).isEqualTo(tinySize); + assertThat(cache.getCurrentSizeBytes()).isEqualTo(tinySize); + + // shrinking size should not evict + int newMax = size - 100; + assertThat(newMax).isLessThan(size); + cache.setMaximumSizeBytes(newMax); + + onIdleWorkerThread(); + assertThat(getSize(cacheDir)).isAtMost(tinySize); + assertThat(cache.getCurrentSizeBytes()).isAtMost(tinySize); + } + + @Test + public void setMaximumSizeBytes_decreaseCacheSize_evictEntries() { + String key = "key"; + File toPut = cache.beginPut(key); + + try { + // write a file that satisfies original cache space + byte[] bytes = new byte[size]; + DiskCacheUtils.writeToFile(toPut, bytes); + cache.commitPut(key, toPut); + } finally { + cache.abortPutIfNotCommitted(key, toPut); + } + + onIdleWorkerThread(); + assertThat(getSize(cacheDir)).isEqualTo(size); + assertThat(cache.getCurrentSizeBytes()).isEqualTo(size); + + // shrinking size should evict cache as needed + int newMax = size - 100; + assertThat(newMax).isLessThan(size); + cache.setMaximumSizeBytes(newMax); + + onIdleWorkerThread(); + assertThat(getSize(cacheDir)).isAtMost(newMax); + assertThat(cache.getCurrentSizeBytes()).isAtMost(newMax); + } + + @Test + public void setMaximumSizeBytes_decreaseCacheSize_evictStaleEntries() { + String keyStale = "keyStale"; + String keyLru = "keyLru"; + File toPutStale = cache.beginPut(keyStale); + File toPutLru = cache.beginPut(keyLru); + int smallSizeBytes = 1; + + try { + byte[] bytes = new byte[size - smallSizeBytes]; + DiskCacheUtils.writeToFile(toPutStale, bytes); + cache.commitPut(keyStale, toPutStale); + } finally { + cache.abortPutIfNotCommitted(keyStale, toPutStale); + } + + // make the next entry far ahead in the future + testClock.set(90); + + try { + byte[] bytes = new byte[smallSizeBytes]; + DiskCacheUtils.writeToFile(toPutLru, bytes); + cache.commitPut(keyLru, toPutLru); + } finally { + cache.abortPutIfNotCommitted(keyLru, toPutLru); + } + + onIdleWorkerThread(); + assertThat(getSize(cacheDir)).isEqualTo(size); + assertThat(cache.getCurrentSizeBytes()).isEqualTo(size); + + // shrinking size should evict cache as needed + int newMax = size - 100; + assertThat(newMax).isLessThan(size); + assertThat(smallSizeBytes).isLessThan(newMax); + cache.setMaximumSizeBytes(newMax); + + onIdleWorkerThread(); + assertThat(getSize(cacheDir)).isAtMost(smallSizeBytes); + assertThat(cache.getCurrentSizeBytes()).isAtMost(smallSizeBytes); + } + + @Test + public void setMaximumSizeBytes_decreaseCacheSize_evictLruEntries() { + String keyStale = "keyStale"; + String keyLru = "keyLru"; + File toPutStale = cache.beginPut(keyStale); + File toPutLru = cache.beginPut(keyLru); + int smallSizeBytes = 1; + + try { + byte[] bytes = new byte[smallSizeBytes]; + DiskCacheUtils.writeToFile(toPutStale, bytes); + cache.commitPut(keyStale, toPutStale); + } finally { + cache.abortPutIfNotCommitted(keyStale, toPutStale); + } + + // make the next entry far ahead in the future + testClock.set(90); + + try { + byte[] bytes = new byte[size - smallSizeBytes]; + DiskCacheUtils.writeToFile(toPutLru, bytes); + cache.commitPut(keyLru, toPutLru); + } finally { + cache.abortPutIfNotCommitted(keyLru, toPutLru); + } + + onIdleWorkerThread(); + assertThat(getSize(cacheDir)).isEqualTo(size); + assertThat(cache.getCurrentSizeBytes()).isEqualTo(size); + + // shrinking size should evict cache as needed + int newMax = size - 100; + assertThat(newMax).isLessThan(size); + assertThat(smallSizeBytes).isLessThan(newMax); + cache.setMaximumSizeBytes(newMax); + + onIdleWorkerThread(); + assertThat(getSize(cacheDir)).isAtMost(smallSizeBytes); + assertThat(cache.getCurrentSizeBytes()).isAtMost(smallSizeBytes); + } + + private static long getSize(File file) { + long result = 0; + if (file.isDirectory()) { + for (File f : file.listFiles()) { + result += getSize(f); + } + } else { + result = file.length(); + } + return result; + } + + private static String readFromFile(File file) { + byte[] data = DiskCacheUtils.readFromFile(file); + return new String(data); + } +} diff --git a/integration/sqljournaldiskcache/src/test/java/com/bumptech/glide/integration/sqljournaldiskcache/TestClock.java b/integration/sqljournaldiskcache/src/test/java/com/bumptech/glide/integration/sqljournaldiskcache/TestClock.java new file mode 100644 index 0000000000..5447d7d8e7 --- /dev/null +++ b/integration/sqljournaldiskcache/src/test/java/com/bumptech/glide/integration/sqljournaldiskcache/TestClock.java @@ -0,0 +1,20 @@ +package com.bumptech.glide.integration.sqljournaldiskcache; + +import java.time.Duration; + +final class TestClock implements Clock { + private long currentTimeMillis = 0L; + + @Override + public long currentTimeMillis() { + return currentTimeMillis; + } + + void set(long timeMillis) { + currentTimeMillis = timeMillis; + } + + void advance(Duration duration) { + currentTimeMillis += duration.toMillis(); + } +} diff --git a/integration/volley/build.gradle b/integration/volley/build.gradle deleted file mode 100644 index 4be5bf8792..0000000000 --- a/integration/volley/build.gradle +++ /dev/null @@ -1,33 +0,0 @@ -apply plugin: 'com.android.library' - -dependencies { - implementation project(':library') - api "com.android.volley:volley:${VOLLEY_VERSION}" - api "androidx.annotation:annotation:${ANDROID_X_ANNOTATION_VERSION}" - annotationProcessor project(':annotation:compiler') - - testImplementation project(":testutil") - testImplementation "com.google.truth:truth:${TRUTH_VERSION}" - testImplementation "junit:junit:${JUNIT_VERSION}" - testImplementation "org.mockito:mockito-core:${MOCKITO_VERSION}" - testImplementation "org.robolectric:robolectric:${ROBOLECTRIC_VERSION}" - testImplementation "com.squareup.okhttp3:mockwebserver:${MOCKWEBSERVER_VERSION}" -} - -android { - compileSdkVersion COMPILE_SDK_VERSION as int - - defaultConfig { - minSdkVersion MIN_SDK_VERSION as int - targetSdkVersion TARGET_SDK_VERSION as int - - versionName VERSION_NAME as String - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 - } -} - -apply from: "${rootProject.projectDir}/scripts/upload.gradle" diff --git a/integration/volley/build.gradle.kts b/integration/volley/build.gradle.kts new file mode 100644 index 0000000000..f294b39792 --- /dev/null +++ b/integration/volley/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "com.bumptech.glide.integration.volley" + + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + useLibrary("org.apache.http.legacy") +} + +dependencies { + implementation(project(":library")) + api(libs.volley) + api(libs.androidx.annotation) + + annotationProcessor(project(":annotation:compiler")) + + testImplementation(project(":testutil")) + testImplementation(libs.truth) + testImplementation(libs.junit) + testImplementation(libs.mockito.core) + testImplementation(libs.robolectric) + testImplementation(libs.mockwebserver) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.androidx.test.runner) +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") \ No newline at end of file diff --git a/integration/volley/src/main/AndroidManifest.xml b/integration/volley/src/main/AndroidManifest.xml index af744318ae..11efaf5799 100644 --- a/integration/volley/src/main/AndroidManifest.xml +++ b/integration/volley/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + - - - diff --git a/library/src/main/java/com/bumptech/glide/GeneratedAppGlideModule.java b/library/src/main/java/com/bumptech/glide/GeneratedAppGlideModule.java index 5f58e1b9dd..04d0f1ec2c 100644 --- a/library/src/main/java/com/bumptech/glide/GeneratedAppGlideModule.java +++ b/library/src/main/java/com/bumptech/glide/GeneratedAppGlideModule.java @@ -4,6 +4,7 @@ import androidx.annotation.Nullable; import com.bumptech.glide.manager.RequestManagerRetriever; import com.bumptech.glide.module.AppGlideModule; +import java.util.HashSet; import java.util.Set; /** @@ -15,7 +16,9 @@ abstract class GeneratedAppGlideModule extends AppGlideModule { /** This method can be removed when manifest parsing is no longer supported. */ @NonNull - abstract Set> getExcludedModuleClasses(); + Set> getExcludedModuleClasses() { + return new HashSet<>(); + } @Nullable RequestManagerRetriever.RequestManagerFactory getRequestManagerFactory() { diff --git a/library/src/main/java/com/bumptech/glide/GenericTransitionOptions.java b/library/src/main/java/com/bumptech/glide/GenericTransitionOptions.java index ee9c684925..ca55aa2a6e 100644 --- a/library/src/main/java/com/bumptech/glide/GenericTransitionOptions.java +++ b/library/src/main/java/com/bumptech/glide/GenericTransitionOptions.java @@ -55,4 +55,18 @@ public static GenericTransitionOptions with( @NonNull TransitionFactory transitionFactory) { return new GenericTransitionOptions().transition(transitionFactory); } + + // Make sure that we're not equal to any other concrete implementation of TransitionOptions. + @Override + public boolean equals(Object o) { + return o instanceof GenericTransitionOptions && super.equals(o); + } + + // Our class doesn't include any additional properties, so we don't need to modify hashcode, but + // keep it here as a reminder in case we add properties. + @SuppressWarnings("PMD.UselessOverridingMethod") + @Override + public int hashCode() { + return super.hashCode(); + } } diff --git a/library/src/main/java/com/bumptech/glide/Glide.java b/library/src/main/java/com/bumptech/glide/Glide.java index 4951007c00..066d02f788 100644 --- a/library/src/main/java/com/bumptech/glide/Glide.java +++ b/library/src/main/java/com/bumptech/glide/Glide.java @@ -1,19 +1,13 @@ package com.bumptech.glide; import android.app.Activity; +import android.app.Application; import android.content.ComponentCallbacks2; -import android.content.ContentResolver; import android.content.Context; -import android.content.res.AssetFileDescriptor; import android.content.res.Configuration; -import android.content.res.Resources; import android.graphics.Bitmap; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.os.Build; +import android.os.Bundle; import android.os.MessageQueue.IdleHandler; -import android.os.ParcelFileDescriptor; import android.util.Log; import android.view.View; import androidx.annotation.GuardedBy; @@ -22,13 +16,7 @@ import androidx.annotation.VisibleForTesting; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; -import com.bumptech.glide.GlideBuilder.EnableImageDecoderForBitmaps; -import com.bumptech.glide.gifdecoder.GifDecoder; import com.bumptech.glide.load.DecodeFormat; -import com.bumptech.glide.load.ImageHeaderParser; -import com.bumptech.glide.load.ResourceDecoder; -import com.bumptech.glide.load.data.InputStreamRewinder; -import com.bumptech.glide.load.data.ParcelFileDescriptorRewinder; import com.bumptech.glide.load.engine.Engine; import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; @@ -36,67 +24,24 @@ import com.bumptech.glide.load.engine.prefill.BitmapPreFiller; import com.bumptech.glide.load.engine.prefill.PreFillType; import com.bumptech.glide.load.engine.prefill.PreFillType.Builder; -import com.bumptech.glide.load.model.AssetUriLoader; -import com.bumptech.glide.load.model.ByteArrayLoader; -import com.bumptech.glide.load.model.ByteBufferEncoder; -import com.bumptech.glide.load.model.ByteBufferFileLoader; -import com.bumptech.glide.load.model.DataUrlLoader; -import com.bumptech.glide.load.model.FileLoader; -import com.bumptech.glide.load.model.GlideUrl; -import com.bumptech.glide.load.model.MediaStoreFileLoader; -import com.bumptech.glide.load.model.ResourceLoader; -import com.bumptech.glide.load.model.StreamEncoder; -import com.bumptech.glide.load.model.StringLoader; -import com.bumptech.glide.load.model.UnitModelLoader; -import com.bumptech.glide.load.model.UriLoader; -import com.bumptech.glide.load.model.UrlUriLoader; -import com.bumptech.glide.load.model.stream.HttpGlideUrlLoader; -import com.bumptech.glide.load.model.stream.MediaStoreImageThumbLoader; -import com.bumptech.glide.load.model.stream.MediaStoreVideoThumbLoader; -import com.bumptech.glide.load.model.stream.QMediaStoreUriLoader; -import com.bumptech.glide.load.model.stream.UrlLoader; -import com.bumptech.glide.load.resource.bitmap.BitmapDrawableDecoder; -import com.bumptech.glide.load.resource.bitmap.BitmapDrawableEncoder; -import com.bumptech.glide.load.resource.bitmap.BitmapEncoder; -import com.bumptech.glide.load.resource.bitmap.ByteBufferBitmapDecoder; -import com.bumptech.glide.load.resource.bitmap.ByteBufferBitmapImageDecoderResourceDecoder; -import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser; import com.bumptech.glide.load.resource.bitmap.Downsampler; -import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser; import com.bumptech.glide.load.resource.bitmap.HardwareConfigState; -import com.bumptech.glide.load.resource.bitmap.InputStreamBitmapImageDecoderResourceDecoder; -import com.bumptech.glide.load.resource.bitmap.ParcelFileDescriptorBitmapDecoder; -import com.bumptech.glide.load.resource.bitmap.ResourceBitmapDecoder; -import com.bumptech.glide.load.resource.bitmap.StreamBitmapDecoder; -import com.bumptech.glide.load.resource.bitmap.UnitBitmapDecoder; -import com.bumptech.glide.load.resource.bitmap.VideoDecoder; -import com.bumptech.glide.load.resource.bytes.ByteBufferRewinder; -import com.bumptech.glide.load.resource.drawable.ResourceDrawableDecoder; -import com.bumptech.glide.load.resource.drawable.UnitDrawableDecoder; -import com.bumptech.glide.load.resource.file.FileDecoder; -import com.bumptech.glide.load.resource.gif.ByteBufferGifDecoder; -import com.bumptech.glide.load.resource.gif.GifDrawable; -import com.bumptech.glide.load.resource.gif.GifDrawableEncoder; -import com.bumptech.glide.load.resource.gif.GifFrameResourceDecoder; -import com.bumptech.glide.load.resource.gif.StreamGifDecoder; -import com.bumptech.glide.load.resource.transcode.BitmapBytesTranscoder; -import com.bumptech.glide.load.resource.transcode.BitmapDrawableTranscoder; -import com.bumptech.glide.load.resource.transcode.DrawableBytesTranscoder; -import com.bumptech.glide.load.resource.transcode.GifDrawableBytesTranscoder; import com.bumptech.glide.manager.ConnectivityMonitorFactory; import com.bumptech.glide.manager.RequestManagerRetriever; +import com.bumptech.glide.module.AppGlideModule; +import com.bumptech.glide.module.GlideModule; import com.bumptech.glide.module.ManifestParser; import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.RequestOptions; import com.bumptech.glide.request.target.ImageViewTargetFactory; import com.bumptech.glide.request.target.Target; +import com.bumptech.glide.util.GlideSuppliers; +import com.bumptech.glide.util.GlideSuppliers.GlideSupplier; import com.bumptech.glide.util.Preconditions; +import com.bumptech.glide.util.Synthetic; import com.bumptech.glide.util.Util; import java.io.File; -import java.io.InputStream; import java.lang.reflect.InvocationTargetException; -import java.net.URL; -import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; @@ -111,6 +56,10 @@ */ public class Glide implements ComponentCallbacks2 { private static final String DEFAULT_DISK_CACHE_DIR = "image_manager_disk_cache"; + private static final String DESTROYED_ACTIVITY_WARNING = + "You cannot start a load on a not yet attached View or a Fragment where getActivity() " + + "returns null (which usually occurs when getActivity() is called before the Fragment " + + "is attached or after the Fragment is destroyed)."; private static final String TAG = "Glide"; @GuardedBy("Glide.class") @@ -122,7 +71,6 @@ public class Glide implements ComponentCallbacks2 { private final BitmapPool bitmapPool; private final MemoryCache memoryCache; private final GlideContext glideContext; - private final Registry registry; private final ArrayPool arrayPool; private final RequestManagerRetriever requestManagerRetriever; private final ConnectivityMonitorFactory connectivityMonitorFactory; @@ -137,6 +85,13 @@ public class Glide implements ComponentCallbacks2 { @Nullable private BitmapPreFiller bitmapPreFiller; + private boolean inBackground; + private MemoryCategory memoryCategoryInBackground; + private MemoryCategory memoryCategoryInForeground = MemoryCategory.NORMAL; + + private final GlideSupplier setMemoryCategoryCallbacks = + GlideSuppliers.memorize(SetMemoryCategoryOnLifecycleCallbacks::new); + /** * Returns a directory with a default name in the private cache directory of the application to * use to store retrieved media and thumbnails. @@ -197,18 +152,21 @@ public static Glide get(@NonNull Context context) { } @GuardedBy("Glide.class") - private static void checkAndInitializeGlide( + @VisibleForTesting + static void checkAndInitializeGlide( @NonNull Context context, @Nullable GeneratedAppGlideModule generatedAppGlideModule) { // In the thread running initGlide(), one or more classes may call Glide.get(context). // Without this check, those calls could trigger infinite recursion. if (isInitializing) { throw new IllegalStateException( - "You cannot call Glide.get() in registerComponents()," - + " use the provided Glide instance instead"); + "Glide has been called recursively, this is probably an internal library error!"); } isInitializing = true; - initializeGlide(context, generatedAppGlideModule); - isInitializing = false; + try { + initializeGlide(context, generatedAppGlideModule); + } finally { + isInitializing = false; + } } /** @@ -236,6 +194,11 @@ public static void init(@NonNull Context context, @NonNull GlideBuilder builder) } } + @VisibleForTesting + public static synchronized boolean isInitialized() { + return glide != null; + } + /** * Allows hardware Bitmaps to be used prior to the first frame in the app being drawn as soon as * this method is called. @@ -254,6 +217,7 @@ public static void tearDown() { synchronized (Glide.class) { if (glide != null) { glide.getContext().getApplicationContext().unregisterComponentCallbacks(glide); + glide.unregisterActivityLifecycleCallbacks(); glide.engine.shutdown(); } glide = null; @@ -273,7 +237,7 @@ private static void initializeGlide( @NonNull GlideBuilder builder, @Nullable GeneratedAppGlideModule annotationGeneratedModule) { Context applicationContext = context.getApplicationContext(); - List manifestModules = Collections.emptyList(); + List manifestModules = Collections.emptyList(); if (annotationGeneratedModule == null || annotationGeneratedModule.isManifestParsingEnabled()) { manifestModules = new ManifestParser(applicationContext).parse(); } @@ -281,9 +245,9 @@ private static void initializeGlide( if (annotationGeneratedModule != null && !annotationGeneratedModule.getExcludedModuleClasses().isEmpty()) { Set> excludedModuleClasses = annotationGeneratedModule.getExcludedModuleClasses(); - Iterator iterator = manifestModules.iterator(); + Iterator iterator = manifestModules.iterator(); while (iterator.hasNext()) { - com.bumptech.glide.module.GlideModule current = iterator.next(); + GlideModule current = iterator.next(); if (!excludedModuleClasses.contains(current.getClass())) { continue; } @@ -295,7 +259,7 @@ private static void initializeGlide( } if (Log.isLoggable(TAG, Log.DEBUG)) { - for (com.bumptech.glide.module.GlideModule glideModule : manifestModules) { + for (GlideModule glideModule : manifestModules) { Log.d(TAG, "Discovered GlideModule from manifest: " + glideModule.getClass()); } } @@ -305,30 +269,15 @@ private static void initializeGlide( ? annotationGeneratedModule.getRequestManagerFactory() : null; builder.setRequestManagerFactory(factory); - for (com.bumptech.glide.module.GlideModule module : manifestModules) { + for (GlideModule module : manifestModules) { module.applyOptions(applicationContext, builder); } if (annotationGeneratedModule != null) { annotationGeneratedModule.applyOptions(applicationContext, builder); } - Glide glide = builder.build(applicationContext); - for (com.bumptech.glide.module.GlideModule module : manifestModules) { - try { - module.registerComponents(applicationContext, glide, glide.registry); - } catch (AbstractMethodError e) { - throw new IllegalStateException( - "Attempting to register a Glide v3 module. If you see this, you or one of your" - + " dependencies may be including Glide v3 even though you're using Glide v4." - + " You'll need to find and remove (or update) the offending dependency." - + " The v3 module name is: " - + module.getClass().getName(), - e); - } - } - if (annotationGeneratedModule != null) { - annotationGeneratedModule.registerComponents(applicationContext, glide, glide.registry); - } + Glide glide = builder.build(applicationContext, manifestModules, annotationGeneratedModule); applicationContext.registerComponentCallbacks(glide); + glide.registerActivityLifecycleCallbacks(); Glide.glide = glide; } @@ -385,7 +334,9 @@ private static void throwIncorrectGlideModule(Exception e) { @NonNull RequestOptionsFactory defaultRequestOptionsFactory, @NonNull Map, TransitionOptions> defaultTransitionOptions, @NonNull List> defaultRequestListeners, - GlideExperiments experiments) { + @NonNull List manifestModules, + @Nullable AppGlideModule annotationGeneratedModule, + @NonNull GlideExperiments experiments) { this.engine = engine; this.bitmapPool = bitmapPool; this.arrayPool = arrayPool; @@ -394,205 +345,19 @@ private static void throwIncorrectGlideModule(Exception e) { this.connectivityMonitorFactory = connectivityMonitorFactory; this.defaultRequestOptionsFactory = defaultRequestOptionsFactory; - final Resources resources = context.getResources(); - - registry = new Registry(); - registry.register(new DefaultImageHeaderParser()); - // Right now we're only using this parser for HEIF images, which are only supported on OMR1+. - // If we need this for other file types, we should consider removing this restriction. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { - registry.register(new ExifInterfaceImageHeaderParser()); - } - - List imageHeaderParsers = registry.getImageHeaderParsers(); - - ByteBufferGifDecoder byteBufferGifDecoder = - new ByteBufferGifDecoder(context, imageHeaderParsers, bitmapPool, arrayPool); - ResourceDecoder parcelFileDescriptorVideoDecoder = - VideoDecoder.parcel(bitmapPool); - - // TODO(judds): Make ParcelFileDescriptorBitmapDecoder work with ImageDecoder. - Downsampler downsampler = - new Downsampler( - registry.getImageHeaderParsers(), resources.getDisplayMetrics(), bitmapPool, arrayPool); - - ResourceDecoder byteBufferBitmapDecoder; - ResourceDecoder streamBitmapDecoder; - if (experiments.isEnabled(EnableImageDecoderForBitmaps.class) - && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - streamBitmapDecoder = new InputStreamBitmapImageDecoderResourceDecoder(); - byteBufferBitmapDecoder = new ByteBufferBitmapImageDecoderResourceDecoder(); - } else { - byteBufferBitmapDecoder = new ByteBufferBitmapDecoder(downsampler); - streamBitmapDecoder = new StreamBitmapDecoder(downsampler, arrayPool); - } - - ResourceDrawableDecoder resourceDrawableDecoder = new ResourceDrawableDecoder(context); - ResourceLoader.StreamFactory resourceLoaderStreamFactory = - new ResourceLoader.StreamFactory(resources); - ResourceLoader.UriFactory resourceLoaderUriFactory = new ResourceLoader.UriFactory(resources); - ResourceLoader.FileDescriptorFactory resourceLoaderFileDescriptorFactory = - new ResourceLoader.FileDescriptorFactory(resources); - ResourceLoader.AssetFileDescriptorFactory resourceLoaderAssetFileDescriptorFactory = - new ResourceLoader.AssetFileDescriptorFactory(resources); - BitmapEncoder bitmapEncoder = new BitmapEncoder(arrayPool); - - BitmapBytesTranscoder bitmapBytesTranscoder = new BitmapBytesTranscoder(); - GifDrawableBytesTranscoder gifDrawableBytesTranscoder = new GifDrawableBytesTranscoder(); - - ContentResolver contentResolver = context.getContentResolver(); - - registry - .append(ByteBuffer.class, new ByteBufferEncoder()) - .append(InputStream.class, new StreamEncoder(arrayPool)) - /* Bitmaps */ - .append(Registry.BUCKET_BITMAP, ByteBuffer.class, Bitmap.class, byteBufferBitmapDecoder) - .append(Registry.BUCKET_BITMAP, InputStream.class, Bitmap.class, streamBitmapDecoder); - - if (ParcelFileDescriptorRewinder.isSupported()) { - registry.append( - Registry.BUCKET_BITMAP, - ParcelFileDescriptor.class, - Bitmap.class, - new ParcelFileDescriptorBitmapDecoder(downsampler)); - } - - registry - .append( - Registry.BUCKET_BITMAP, - ParcelFileDescriptor.class, - Bitmap.class, - parcelFileDescriptorVideoDecoder) - .append( - Registry.BUCKET_BITMAP, - AssetFileDescriptor.class, - Bitmap.class, - VideoDecoder.asset(bitmapPool)) - .append(Bitmap.class, Bitmap.class, UnitModelLoader.Factory.getInstance()) - .append(Registry.BUCKET_BITMAP, Bitmap.class, Bitmap.class, new UnitBitmapDecoder()) - .append(Bitmap.class, bitmapEncoder) - /* BitmapDrawables */ - .append( - Registry.BUCKET_BITMAP_DRAWABLE, - ByteBuffer.class, - BitmapDrawable.class, - new BitmapDrawableDecoder<>(resources, byteBufferBitmapDecoder)) - .append( - Registry.BUCKET_BITMAP_DRAWABLE, - InputStream.class, - BitmapDrawable.class, - new BitmapDrawableDecoder<>(resources, streamBitmapDecoder)) - .append( - Registry.BUCKET_BITMAP_DRAWABLE, - ParcelFileDescriptor.class, - BitmapDrawable.class, - new BitmapDrawableDecoder<>(resources, parcelFileDescriptorVideoDecoder)) - .append(BitmapDrawable.class, new BitmapDrawableEncoder(bitmapPool, bitmapEncoder)) - /* GIFs */ - .append( - Registry.BUCKET_GIF, - InputStream.class, - GifDrawable.class, - new StreamGifDecoder(imageHeaderParsers, byteBufferGifDecoder, arrayPool)) - .append(Registry.BUCKET_GIF, ByteBuffer.class, GifDrawable.class, byteBufferGifDecoder) - .append(GifDrawable.class, new GifDrawableEncoder()) - /* GIF Frames */ - // Compilation with Gradle requires the type to be specified for UnitModelLoader here. - .append( - GifDecoder.class, GifDecoder.class, UnitModelLoader.Factory.getInstance()) - .append( - Registry.BUCKET_BITMAP, - GifDecoder.class, - Bitmap.class, - new GifFrameResourceDecoder(bitmapPool)) - /* Drawables */ - .append(Uri.class, Drawable.class, resourceDrawableDecoder) - .append( - Uri.class, Bitmap.class, new ResourceBitmapDecoder(resourceDrawableDecoder, bitmapPool)) - /* Files */ - .register(new ByteBufferRewinder.Factory()) - .append(File.class, ByteBuffer.class, new ByteBufferFileLoader.Factory()) - .append(File.class, InputStream.class, new FileLoader.StreamFactory()) - .append(File.class, File.class, new FileDecoder()) - .append(File.class, ParcelFileDescriptor.class, new FileLoader.FileDescriptorFactory()) - // Compilation with Gradle requires the type to be specified for UnitModelLoader here. - .append(File.class, File.class, UnitModelLoader.Factory.getInstance()) - /* Models */ - .register(new InputStreamRewinder.Factory(arrayPool)); - - if (ParcelFileDescriptorRewinder.isSupported()) { - registry.register(new ParcelFileDescriptorRewinder.Factory()); - } - - registry - .append(int.class, InputStream.class, resourceLoaderStreamFactory) - .append(int.class, ParcelFileDescriptor.class, resourceLoaderFileDescriptorFactory) - .append(Integer.class, InputStream.class, resourceLoaderStreamFactory) - .append(Integer.class, ParcelFileDescriptor.class, resourceLoaderFileDescriptorFactory) - .append(Integer.class, Uri.class, resourceLoaderUriFactory) - .append(int.class, AssetFileDescriptor.class, resourceLoaderAssetFileDescriptorFactory) - .append(Integer.class, AssetFileDescriptor.class, resourceLoaderAssetFileDescriptorFactory) - .append(int.class, Uri.class, resourceLoaderUriFactory) - .append(String.class, InputStream.class, new DataUrlLoader.StreamFactory()) - .append(Uri.class, InputStream.class, new DataUrlLoader.StreamFactory()) - .append(String.class, InputStream.class, new StringLoader.StreamFactory()) - .append(String.class, ParcelFileDescriptor.class, new StringLoader.FileDescriptorFactory()) - .append( - String.class, AssetFileDescriptor.class, new StringLoader.AssetFileDescriptorFactory()) - .append(Uri.class, InputStream.class, new AssetUriLoader.StreamFactory(context.getAssets())) - .append( - Uri.class, - ParcelFileDescriptor.class, - new AssetUriLoader.FileDescriptorFactory(context.getAssets())) - .append(Uri.class, InputStream.class, new MediaStoreImageThumbLoader.Factory(context)) - .append(Uri.class, InputStream.class, new MediaStoreVideoThumbLoader.Factory(context)); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - registry.append( - Uri.class, InputStream.class, new QMediaStoreUriLoader.InputStreamFactory(context)); - registry.append( - Uri.class, - ParcelFileDescriptor.class, - new QMediaStoreUriLoader.FileDescriptorFactory(context)); - } - registry - .append(Uri.class, InputStream.class, new UriLoader.StreamFactory(contentResolver)) - .append( - Uri.class, - ParcelFileDescriptor.class, - new UriLoader.FileDescriptorFactory(contentResolver)) - .append( - Uri.class, - AssetFileDescriptor.class, - new UriLoader.AssetFileDescriptorFactory(contentResolver)) - .append(Uri.class, InputStream.class, new UrlUriLoader.StreamFactory()) - .append(URL.class, InputStream.class, new UrlLoader.StreamFactory()) - .append(Uri.class, File.class, new MediaStoreFileLoader.Factory(context)) - .append(GlideUrl.class, InputStream.class, new HttpGlideUrlLoader.Factory()) - .append(byte[].class, ByteBuffer.class, new ByteArrayLoader.ByteBufferFactory()) - .append(byte[].class, InputStream.class, new ByteArrayLoader.StreamFactory()) - .append(Uri.class, Uri.class, UnitModelLoader.Factory.getInstance()) - .append(Drawable.class, Drawable.class, UnitModelLoader.Factory.getInstance()) - .append(Drawable.class, Drawable.class, new UnitDrawableDecoder()) - /* Transcoders */ - .register(Bitmap.class, BitmapDrawable.class, new BitmapDrawableTranscoder(resources)) - .register(Bitmap.class, byte[].class, bitmapBytesTranscoder) - .register( - Drawable.class, - byte[].class, - new DrawableBytesTranscoder( - bitmapPool, bitmapBytesTranscoder, gifDrawableBytesTranscoder)) - .register(GifDrawable.class, byte[].class, gifDrawableBytesTranscoder); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - ResourceDecoder byteBufferVideoDecoder = - VideoDecoder.byteBuffer(bitmapPool); - registry.append(ByteBuffer.class, Bitmap.class, byteBufferVideoDecoder); - registry.append( - ByteBuffer.class, - BitmapDrawable.class, - new BitmapDrawableDecoder<>(resources, byteBufferVideoDecoder)); + GlideBuilder.MemoryCategoryInBackground memoryCategoryInBackground = + experiments.get(GlideBuilder.MemoryCategoryInBackground.class); + if (memoryCategoryInBackground != null) { + this.memoryCategoryInBackground = memoryCategoryInBackground.value(); } + // This has a circular relationship with Glide and GlideContext in that it depends on both, + // but it's created by Glide's constructor. In practice this shouldn't matter because the + // supplier holding the registry should never be initialized before this constructor finishes. + GlideSupplier registry = + RegistryFactory.lazilyCreateAndInitializeRegistry( + this, manifestModules, annotationGeneratedModule); + ImageViewTargetFactory imageViewTargetFactory = new ImageViewTargetFactory(); glideContext = new GlideContext( @@ -636,7 +401,9 @@ public ArrayPool getArrayPool() { return arrayPool; } - /** @return The context associated with this instance. */ + /** + * @return The context associated with this instance. + */ @NonNull public Context getContext() { return glideContext.getBaseContext(); @@ -766,11 +533,7 @@ public MemoryCategory setMemoryCategory(@NonNull MemoryCategory memoryCategory) private static RequestManagerRetriever getRetriever(@Nullable Context context) { // Context could be null for other reasons (ie the user passes in null), but in practice it will // only occur due to errors with the Fragment lifecycle. - Preconditions.checkNotNull( - context, - "You cannot start a load on a not yet attached View or a Fragment where getActivity() " - + "returns null (which usually occurs when getActivity() is called before the Fragment " - + "is attached or after the Fragment is destroyed)."); + Preconditions.checkNotNull(context, DESTROYED_ACTIVITY_WARNING); return Glide.get(context).getRequestManagerRetriever(); } @@ -807,10 +570,16 @@ public static RequestManager with(@NonNull Context context) { * * @param activity The activity to use. * @return A RequestManager for the given activity that can be used to start a load. + * @deprecated This is equivalent to calling {@link #with(Context)} using the application context. + * Use the androidx Activity class instead (ie {@link FragmentActivity}, or {@link + * androidx.appcompat.app.AppCompatActivity}). + * @throws IllegalArgumentException if the activity associated with the Glide request is being + * destroyed. */ @NonNull + @Deprecated public static RequestManager with(@NonNull Activity activity) { - return getRetriever(activity).get(activity); + return with(activity.getApplicationContext()); } /** @@ -818,8 +587,9 @@ public static RequestManager with(@NonNull Activity activity) { * androidx.fragment.app.FragmentActivity}'s lifecycle and that uses the given {@link * androidx.fragment.app.FragmentActivity}'s default options. * - * @param activity The activity to use. + * @param activity The activity to use. The activity must not be destroyed. * @return A RequestManager for the given FragmentActivity that can be used to start a load. + * @throws IllegalArgumentException if the activity is being destroyed. */ @NonNull public static RequestManager with(@NonNull FragmentActivity activity) { @@ -832,6 +602,7 @@ public static RequestManager with(@NonNull FragmentActivity activity) { * * @param fragment The fragment to use. * @return A RequestManager for the given Fragment that can be used to start a load. + * @throws IllegalArgumentException if the activity associated with the fragment is destroyed. */ @NonNull public static RequestManager with(@NonNull Fragment fragment) { @@ -844,15 +615,17 @@ public static RequestManager with(@NonNull Fragment fragment) { * * @param fragment The fragment to use. * @return A RequestManager for the given Fragment that can be used to start a load. - * @deprecated Prefer support Fragments and {@link #with(Fragment)} instead, {@link - * android.app.Fragment} will be deprecated. See + * @deprecated This method is identical to calling {@link Glide#with(Context)} using the + * application context. Prefer support Fragments and {@link #with(Fragment)} instead. See * https://github.com/android/android-ktx/pull/161#issuecomment-363270555. + * @throws IllegalArgumentException if the activity associated with the fragment is destroyed. */ - @SuppressWarnings("deprecation") @Deprecated @NonNull public static RequestManager with(@NonNull android.app.Fragment fragment) { - return getRetriever(fragment.getActivity()).get(fragment); + Activity activity = fragment.getActivity(); + Preconditions.checkNotNull(activity, DESTROYED_ACTIVITY_WARNING); + return with(activity.getApplicationContext()); } /** @@ -879,6 +652,7 @@ public static RequestManager with(@NonNull android.app.Fragment fragment) { * * @param view The view to search for a containing Fragment or Activity from. * @return A RequestManager that can be used to start a load. + * @throws IllegalArgumentException if the activity associated with the view is destroyed. */ @NonNull public static RequestManager with(@NonNull View view) { @@ -887,7 +661,7 @@ public static RequestManager with(@NonNull View view) { @NonNull public Registry getRegistry() { - return registry; + return glideContext.getRegistry(); } boolean removeFromManagers(@NonNull Target target) { @@ -923,6 +697,11 @@ void unregisterRequestManager(RequestManager requestManager) { @Override public void onTrimMemory(int level) { trimMemory(level); + // when level is higher than TRIM_MEMORY_UI_HIDDEN, it indicates that the app is + // in the background, limit the memory usage by memoryCategoryInBackground. + if (level > TRIM_MEMORY_UI_HIDDEN) { + setMemoryCategoryWhenInBackground(); + } } @Override @@ -942,4 +721,86 @@ public interface RequestOptionsFactory { @NonNull RequestOptions build(); } + + private void registerActivityLifecycleCallbacks() { + if (memoryCategoryInBackground != null) { + Context context = getContext().getApplicationContext(); + if (!(context instanceof Application) && Log.isLoggable(TAG, Log.WARN)) { + Log.w( + TAG, + "Glide requires an Application Context. You passed: " + + context + + ". This will disable setting memory category in background."); + return; + } + ((Application) context).registerActivityLifecycleCallbacks(setMemoryCategoryCallbacks.get()); + } + } + + private void unregisterActivityLifecycleCallbacks() { + if (memoryCategoryInBackground != null) { + Context context = getContext().getApplicationContext(); + if (context instanceof Application) { + ((Application) context) + .unregisterActivityLifecycleCallbacks(setMemoryCategoryCallbacks.get()); + } + } + } + + private void setMemoryCategoryWhenInBackground() { + if (memoryCategoryInBackground == null || inBackground) { + return; + } + inBackground = true; + memoryCategoryInForeground = setMemoryCategory(memoryCategoryInBackground); + } + + @Synthetic + void setMemoryCategoryWhenInForeground() { + if (memoryCategoryInBackground == null || !inBackground) { + return; + } + inBackground = false; + setMemoryCategory(memoryCategoryInForeground); + } + + private final class SetMemoryCategoryOnLifecycleCallbacks + implements Application.ActivityLifecycleCallbacks { + @Override + public void onActivityStarted(Activity activity) { + // Do nothing. + } + + @Override + public void onActivityResumed(Activity activity) { + // Any activity resumed indicates that the app is no longer in the background, + // and we should restore the memory usage to normal. + setMemoryCategoryWhenInForeground(); + } + + @Override + public void onActivityCreated(Activity activity, Bundle savedInstanceState) { + // Do nothing. + } + + @Override + public void onActivityDestroyed(Activity activity) { + // Do nothing. + } + + @Override + public void onActivityStopped(Activity activity) { + // Do nothing. + } + + @Override + public void onActivitySaveInstanceState(Activity activity, Bundle outState) { + // Do nothing. + } + + @Override + public void onActivityPaused(Activity activity) { + // Do nothing. + } + } } diff --git a/library/src/main/java/com/bumptech/glide/GlideBuilder.java b/library/src/main/java/com/bumptech/glide/GlideBuilder.java index 41373766ba..4f051b394d 100644 --- a/library/src/main/java/com/bumptech/glide/GlideBuilder.java +++ b/library/src/main/java/com/bumptech/glide/GlideBuilder.java @@ -27,6 +27,8 @@ import com.bumptech.glide.manager.DefaultConnectivityMonitorFactory; import com.bumptech.glide.manager.RequestManagerRetriever; import com.bumptech.glide.manager.RequestManagerRetriever.RequestManagerFactory; +import com.bumptech.glide.module.AppGlideModule; +import com.bumptech.glide.module.GlideModule; import com.bumptech.glide.request.BaseRequestOptions; import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.RequestOptions; @@ -481,7 +483,124 @@ public GlideBuilder setLogRequestOrigins(boolean isEnabled) { public GlideBuilder setImageDecoderEnabledForBitmaps(boolean isEnabled) { glideExperimentsBuilder.update( new EnableImageDecoderForBitmaps(), - /*isEnabled=*/ isEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + /* isEnabled= */ isEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + return this; + } + + /** + * Set to {@code true} to make Glide use {@link android.graphics.ImageDecoder} when decoding + * {@link Bitmap}s from local {@link android.net.Uri}s on Android Q and higher. + * + *

This functionality is also guarded by {@link #setImageDecoderEnabledForBitmaps(boolean)} and + * will only be active if that flag is also enabled. + * + *

Calls to this method on versions of Android less than Q are ignored. + * + *

This flag is experimental and may be removed without deprecation in a future version. + */ + public GlideBuilder setUriImageDecoderEnabled(boolean isEnabled) { + glideExperimentsBuilder.update( + new EnableUriImageDecoder(), + /* isEnabled= */ isEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q); + return this; + } + + /** + * Set to {@code true} to make Glide use a heap buffer instead of a direct buffer when decoding + * {@link Bitmap}s from an {@link java.io.InputStream} using {@link + * android.graphics.ImageDecoder}. + * + *

This flag is experimental and may be removed without deprecation in a future version. + */ + public GlideBuilder setUseHeapBufferForImageDecoderWithInputStream(boolean isEnabled) { + glideExperimentsBuilder.update(new UseHeapBufferForImageDecoderWithInputStream(), isEnabled); + return this; + } + + /** + * Set to {@code true} to make Glide pool intermediate reading buffers and allocate precisely one + * tailored {@link java.nio.ByteBuffer} using {@link ArrayPool} when decoding from an {@link + * java.io.InputStream} via {@link android.graphics.ImageDecoder}. + * + *

This flag is experimental and may be removed without deprecation in a future version. + */ + public GlideBuilder setUseArrayPoolForImageDecoderByteBufferAllocation(boolean isEnabled) { + glideExperimentsBuilder.update( + new UseArrayPoolForImageDecoderByteBufferAllocation(), isEnabled); + return this; + } + + /** + * Set to {@code true} to enable direct {@link java.nio.ByteBuffer} decoding instead of wrapping + * buffers in an {@link java.io.InputStream}. Disabled by default. + * + *

This flag is experimental and may be removed without deprecation in a future version. + */ + public GlideBuilder setEnableDirectByteBufferDecoding(boolean isEnabled) { + glideExperimentsBuilder.update(new EnableDirectByteBufferDecoding(), isEnabled); + return this; + } + + /** + * Override the OS thread priority of threads created in {@code + * com.bumptech.glide.load.engine.executor.GlideExecutor.DefaultThreadFactory} with {@link + * com.bumptech.glide.load.engine.DecodeJob#GLIDE_THREAD_PRIORITY_OVERRIDE} Glide Option. + * + *

This is an experimental API that may be removed in the future. + */ + public GlideBuilder setOverrideGlideThreadPriority(boolean isEnabled) { + glideExperimentsBuilder.update(new OverrideGlideThreadPriority(), isEnabled); + return this; + } + + /** + * Set to {@code true} to make Glide use {@code + * android.provider.MediaStore#openAssetFileDescriptor(ContentResolver, Uri, String, + * CancellationSignal)} when opening {@link android.provider.MediaStore#AUTHORITY} content URIs + * when it is available. + * + *

This is an experimental API that may be removed in the future. + */ + public GlideBuilder setUseMediaStoreOpenFileApisIfPossible(boolean isEnabled) { + glideExperimentsBuilder.update(new UseMediaStoreOpenFileApisIfPossible(), isEnabled); + return this; + } + + /** + * Set to {@code true} to make Glide use {@link MemoryCategory} to set the memory category when + * the app is in the background. + * + *

This is an experimental API that may be removed in the future. + */ + public GlideBuilder setMemoryCategoryInBackground(MemoryCategory memoryCategory) { + glideExperimentsBuilder.add(new MemoryCategoryInBackground(memoryCategory)); + return this; + } + + /** + * @deprecated This method does nothing. It will be hard coded and removed in a future release + * without further warning. + */ + @Deprecated + public GlideBuilder setPreserveGainmapAndColorSpaceForTransformations(boolean isEnabled) { + return this; + } + + /** + * @deprecated This method does nothing. It will be hard coded and removed in a future release + * without further warning. + */ + @Deprecated + public GlideBuilder setEnableHardwareGainmapFixOnU(boolean isEnabled) { + return this; + } + + /** + * @deprecated This method does nothing. It will be hard coded and removed in a future release + * without further warning. + */ + @Deprecated + public GlideBuilder setDisableHardwareBitmapsOnO(boolean disableHardwareBitmapsOnO) { return this; } @@ -496,7 +615,10 @@ GlideBuilder setEngine(Engine engine) { } @NonNull - Glide build(@NonNull Context context) { + Glide build( + @NonNull Context context, + List manifestModules, + AppGlideModule annotationGeneratedGlideModule) { if (sourceExecutor == null) { sourceExecutor = GlideExecutor.newSourceExecutor(); } @@ -558,7 +680,7 @@ Glide build(@NonNull Context context) { GlideExperiments experiments = glideExperimentsBuilder.build(); RequestManagerRetriever requestManagerRetriever = - new RequestManagerRetriever(requestManagerFactory, experiments); + new RequestManagerRetriever(requestManagerFactory); return new Glide( context, @@ -572,6 +694,8 @@ Glide build(@NonNull Context context) { defaultRequestOptionsFactory, defaultTransitionOptions, defaultRequestListeners, + manifestModules, + annotationGeneratedGlideModule, experiments); } @@ -584,13 +708,38 @@ static final class ManualOverrideHardwareBitmapMaxFdCount implements Experiment } } - /** See {@link #setWaitForFramesAfterTrimMemory(boolean)}. */ - public static final class WaitForFramesAfterTrimMemory implements Experiment { - private WaitForFramesAfterTrimMemory() {} - } - static final class EnableImageDecoderForBitmaps implements Experiment {} + static final class EnableUriImageDecoder implements Experiment {} + + /** See {@link #setUseHeapBufferForImageDecoderWithInputStream(boolean)}. */ + public static final class UseHeapBufferForImageDecoderWithInputStream implements Experiment {} + + /** See {@link #setUseArrayPoolForImageDecoderByteBufferAllocation(boolean)}. */ + public static final class UseArrayPoolForImageDecoderByteBufferAllocation implements Experiment {} + + /** See {@link #setEnableDirectByteBufferDecoding(boolean)}. */ + public static final class EnableDirectByteBufferDecoding implements Experiment {} + /** See {@link #setLogRequestOrigins(boolean)}. */ public static final class LogRequestOrigins implements Experiment {} + + /** See {@link #setOverrideGlideThreadPriority(boolean)}. */ + public static final class OverrideGlideThreadPriority implements Experiment {} + + /** See {@link #setUseMediaStoreOpenFileApisIfPossible(boolean)}. */ + public static final class UseMediaStoreOpenFileApisIfPossible implements Experiment {} + + /** See {@link #setMemoryCategoryInBackground(MemoryCategory)}. */ + public static final class MemoryCategoryInBackground implements Experiment { + private final MemoryCategory memoryCategory; + + MemoryCategoryInBackground(MemoryCategory memoryCategory) { + this.memoryCategory = memoryCategory; + } + + public MemoryCategory value() { + return memoryCategory; + } + } } diff --git a/library/src/main/java/com/bumptech/glide/GlideContext.java b/library/src/main/java/com/bumptech/glide/GlideContext.java index f312830b18..7be48425c3 100644 --- a/library/src/main/java/com/bumptech/glide/GlideContext.java +++ b/library/src/main/java/com/bumptech/glide/GlideContext.java @@ -14,6 +14,8 @@ import com.bumptech.glide.request.RequestOptions; import com.bumptech.glide.request.target.ImageViewTargetFactory; import com.bumptech.glide.request.target.ViewTarget; +import com.bumptech.glide.util.GlideSuppliers; +import com.bumptech.glide.util.GlideSuppliers.GlideSupplier; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -29,7 +31,7 @@ public class GlideContext extends ContextWrapper { new GenericTransitionOptions<>(); private final ArrayPool arrayPool; - private final Registry registry; + private final GlideSupplier registry; private final ImageViewTargetFactory imageViewTargetFactory; private final RequestOptionsFactory defaultRequestOptionsFactory; private final List> defaultRequestListeners; @@ -45,7 +47,7 @@ public class GlideContext extends ContextWrapper { public GlideContext( @NonNull Context context, @NonNull ArrayPool arrayPool, - @NonNull Registry registry, + @NonNull GlideSupplier registry, @NonNull ImageViewTargetFactory imageViewTargetFactory, @NonNull RequestOptionsFactory defaultRequestOptionsFactory, @NonNull Map, TransitionOptions> defaultTransitionOptions, @@ -55,7 +57,6 @@ public GlideContext( int logLevel) { super(context.getApplicationContext()); this.arrayPool = arrayPool; - this.registry = registry; this.imageViewTargetFactory = imageViewTargetFactory; this.defaultRequestOptionsFactory = defaultRequestOptionsFactory; this.defaultRequestListeners = defaultRequestListeners; @@ -63,6 +64,8 @@ public GlideContext( this.engine = engine; this.experiments = experiments; this.logLevel = logLevel; + + this.registry = GlideSuppliers.memorize(registry); } public List> getDefaultRequestListeners() { @@ -107,7 +110,7 @@ public Engine getEngine() { @NonNull public Registry getRegistry() { - return registry; + return registry.get(); } public int getLogLevel() { diff --git a/library/src/main/java/com/bumptech/glide/ListPreloader.java b/library/src/main/java/com/bumptech/glide/ListPreloader.java index 2c1f7f4628..9946fd0337 100644 --- a/library/src/main/java/com/bumptech/glide/ListPreloader.java +++ b/library/src/main/java/com/bumptech/glide/ListPreloader.java @@ -52,6 +52,10 @@ public interface PreloadModelProvider { * Returns a {@link List} of models that need to be loaded for the list to display adapter items * in positions between {@code start} and {@code end}. * + *

{@code position} is the position in the view. If the view contains a mix of types (e.g. + * headers and images) then not every view position will actually have any model to return here. + * If that's the case for the given {@code position}, then return an empty list. + * *

A list of any size can be returned so there can be multiple models per adapter position. * *

Every model returned by this method is expected to produce a valid {@link RequestBuilder} @@ -141,6 +145,9 @@ public void onScrollStateChanged(AbsListView absListView, int scrollState) { @Override public void onScroll( AbsListView absListView, int firstVisible, int visibleCount, int totalCount) { + if (totalItemCount == 0 && totalCount == 0) { + return; + } totalItemCount = totalCount; if (firstVisible > lastFirstVisible) { preload(firstVisible + visibleCount, true); @@ -174,12 +181,14 @@ private void preload(int from, int to) { if (from < to) { // Increasing for (int i = start; i < end; i++) { - preloadAdapterPosition(preloadModelProvider.getPreloadItems(i), i, true); + preloadAdapterPosition( + preloadModelProvider.getPreloadItems(i), /* position= */ i, /* isIncreasing= */ true); } } else { // Decreasing for (int i = end - 1; i >= start; i--) { - preloadAdapterPosition(preloadModelProvider.getPreloadItems(i), i, false); + preloadAdapterPosition( + preloadModelProvider.getPreloadItems(i), /* position= */ i, /* isIncreasing= */ false); } } diff --git a/library/src/main/java/com/bumptech/glide/MemoryCategory.java b/library/src/main/java/com/bumptech/glide/MemoryCategory.java index 0e71072059..7f530a5305 100644 --- a/library/src/main/java/com/bumptech/glide/MemoryCategory.java +++ b/library/src/main/java/com/bumptech/glide/MemoryCategory.java @@ -2,6 +2,8 @@ /** An enum for dynamically modifying the amount of memory Glide is able to use. */ public enum MemoryCategory { + /** Tells Glide's memory cache and bitmap pool to use no memory. */ + ZERO(0f), /** * Tells Glide's memory cache and bitmap pool to use at most half of their initial maximum size. */ diff --git a/library/src/main/java/com/bumptech/glide/Registry.java b/library/src/main/java/com/bumptech/glide/Registry.java index 0bf4eb3543..780237ab9c 100644 --- a/library/src/main/java/com/bumptech/glide/Registry.java +++ b/library/src/main/java/com/bumptech/glide/Registry.java @@ -37,7 +37,14 @@ // Public API. @SuppressWarnings({"WeakerAccess", "unused"}) public class Registry { - public static final String BUCKET_GIF = "Gif"; + public static final String BUCKET_ANIMATION = "Animation"; + + /** + * @deprecated Identical to {@link #BUCKET_ANIMATION}, just with a more confusing name. This + * bucket can be used for all animation types (including webp). + */ + @Deprecated public static final String BUCKET_GIF = BUCKET_ANIMATION; + public static final String BUCKET_BITMAP = "Bitmap"; public static final String BUCKET_BITMAP_DRAWABLE = "BitmapDrawable"; private static final String BUCKET_PREPEND_ALL = "legacy_prepend_all"; @@ -65,7 +72,7 @@ public Registry() { this.transcoderRegistry = new TranscoderRegistry(); this.imageHeaderParserRegistry = new ImageHeaderParserRegistry(); setResourceDecoderBucketPriorityList( - Arrays.asList(BUCKET_GIF, BUCKET_BITMAP, BUCKET_BITMAP_DRAWABLE)); + Arrays.asList(BUCKET_ANIMATION, BUCKET_BITMAP, BUCKET_BITMAP_DRAWABLE)); } /** @@ -246,7 +253,7 @@ public Registry prepend( * which are identified as a unique string. Glide will attempt to decode using decoders in the * highest priority bucket before moving on to the next one. * - *

The default order is [{@link #BUCKET_GIF}, {@link #BUCKET_BITMAP}, {@link + *

The default order is [{@link #BUCKET_ANIMATION}, {@link #BUCKET_BITMAP}, {@link * #BUCKET_BITMAP_DRAWABLE}]. * *

When registering decoders, you can use these buckets to specify the ordering relative only diff --git a/library/src/main/java/com/bumptech/glide/RegistryFactory.java b/library/src/main/java/com/bumptech/glide/RegistryFactory.java new file mode 100644 index 0000000000..9e9a37711d --- /dev/null +++ b/library/src/main/java/com/bumptech/glide/RegistryFactory.java @@ -0,0 +1,450 @@ +package com.bumptech.glide; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.ParcelFileDescriptor; +import androidx.annotation.Nullable; +import androidx.tracing.Trace; +import com.bumptech.glide.GlideBuilder.EnableImageDecoderForBitmaps; +import com.bumptech.glide.GlideBuilder.EnableUriImageDecoder; +import com.bumptech.glide.GlideBuilder.UseMediaStoreOpenFileApisIfPossible; +import com.bumptech.glide.gifdecoder.GifDecoder; +import com.bumptech.glide.load.ImageHeaderParser; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.data.InputStreamRewinder; +import com.bumptech.glide.load.data.ParcelFileDescriptorRewinder; +import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; +import com.bumptech.glide.load.model.AssetUriLoader; +import com.bumptech.glide.load.model.ByteArrayLoader; +import com.bumptech.glide.load.model.ByteBufferEncoder; +import com.bumptech.glide.load.model.ByteBufferFileLoader; +import com.bumptech.glide.load.model.DataUrlLoader; +import com.bumptech.glide.load.model.DirectResourceLoader; +import com.bumptech.glide.load.model.FileLoader; +import com.bumptech.glide.load.model.GlideUrl; +import com.bumptech.glide.load.model.MediaStoreFileLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.ResourceLoader; +import com.bumptech.glide.load.model.ResourceUriLoader; +import com.bumptech.glide.load.model.StreamEncoder; +import com.bumptech.glide.load.model.StringLoader; +import com.bumptech.glide.load.model.UnitModelLoader; +import com.bumptech.glide.load.model.UriLoader; +import com.bumptech.glide.load.model.UrlUriLoader; +import com.bumptech.glide.load.model.stream.HttpGlideUrlLoader; +import com.bumptech.glide.load.model.stream.MediaStoreImageThumbLoader; +import com.bumptech.glide.load.model.stream.MediaStoreVideoThumbLoader; +import com.bumptech.glide.load.model.stream.QMediaStoreUriLoader; +import com.bumptech.glide.load.model.stream.UrlLoader; +import com.bumptech.glide.load.resource.bitmap.BitmapDrawableDecoder; +import com.bumptech.glide.load.resource.bitmap.BitmapDrawableEncoder; +import com.bumptech.glide.load.resource.bitmap.BitmapEncoder; +import com.bumptech.glide.load.resource.bitmap.ByteBufferBitmapDecoder; +import com.bumptech.glide.load.resource.bitmap.ByteBufferBitmapImageDecoderResourceDecoder; +import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser; +import com.bumptech.glide.load.resource.bitmap.Downsampler; +import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser; +import com.bumptech.glide.load.resource.bitmap.InputStreamBitmapImageDecoderResourceDecoder; +import com.bumptech.glide.load.resource.bitmap.ParcelFileDescriptorBitmapDecoder; +import com.bumptech.glide.load.resource.bitmap.ResourceBitmapDecoder; +import com.bumptech.glide.load.resource.bitmap.StreamBitmapDecoder; +import com.bumptech.glide.load.resource.bitmap.UnitBitmapDecoder; +import com.bumptech.glide.load.resource.bitmap.UriBitmapImageDecoderResourceDecoder; +import com.bumptech.glide.load.resource.bitmap.VideoDecoder; +import com.bumptech.glide.load.resource.bytes.ByteBufferRewinder; +import com.bumptech.glide.load.resource.drawable.AnimatedImageDecoder; +import com.bumptech.glide.load.resource.drawable.ResourceDrawableDecoder; +import com.bumptech.glide.load.resource.drawable.UnitDrawableDecoder; +import com.bumptech.glide.load.resource.file.FileDecoder; +import com.bumptech.glide.load.resource.gif.ByteBufferGifDecoder; +import com.bumptech.glide.load.resource.gif.GifDrawable; +import com.bumptech.glide.load.resource.gif.GifDrawableEncoder; +import com.bumptech.glide.load.resource.gif.GifFrameResourceDecoder; +import com.bumptech.glide.load.resource.gif.StreamGifDecoder; +import com.bumptech.glide.load.resource.transcode.BitmapBytesTranscoder; +import com.bumptech.glide.load.resource.transcode.BitmapDrawableTranscoder; +import com.bumptech.glide.load.resource.transcode.DrawableBytesTranscoder; +import com.bumptech.glide.load.resource.transcode.GifDrawableBytesTranscoder; +import com.bumptech.glide.module.AppGlideModule; +import com.bumptech.glide.module.GlideModule; +import com.bumptech.glide.util.GlideSuppliers.GlideSupplier; +import com.bumptech.glide.util.Synthetic; +import java.io.File; +import java.io.InputStream; +import java.net.URL; +import java.nio.ByteBuffer; +import java.util.List; + +final class RegistryFactory { + + private RegistryFactory() {} + + static GlideSupplier lazilyCreateAndInitializeRegistry( + final Glide glide, + final List manifestModules, + @Nullable final AppGlideModule annotationGeneratedModule) { + return new GlideSupplier() { + // Rely on callers using memoization if they want to avoid duplicate work, but + // rely on ourselves to verify that no recursive initialization occurs. + private boolean isInitializing; + + @Override + public Registry get() { + if (isInitializing) { + throw new IllegalStateException( + "Recursive Registry initialization! In your" + + " AppGlideModule and LibraryGlideModules, Make sure you're using the provided " + + "Registry rather calling glide.getRegistry()!"); + } + Trace.beginSection("Glide registry"); + isInitializing = true; + try { + return createAndInitRegistry(glide, manifestModules, annotationGeneratedModule); + } finally { + isInitializing = false; + Trace.endSection(); + } + } + }; + } + + @Synthetic + static Registry createAndInitRegistry( + Glide glide, + List manifestModules, + @Nullable AppGlideModule annotationGeneratedModule) { + + BitmapPool bitmapPool = glide.getBitmapPool(); + ArrayPool arrayPool = glide.getArrayPool(); + Context context = glide.getGlideContext().getApplicationContext(); + + GlideExperiments experiments = glide.getGlideContext().getExperiments(); + + Registry registry = new Registry(); + initializeDefaults(context, registry, bitmapPool, arrayPool, experiments); + initializeModules(context, glide, registry, manifestModules, annotationGeneratedModule); + return registry; + } + + private static void initializeDefaults( + Context context, + Registry registry, + BitmapPool bitmapPool, + ArrayPool arrayPool, + GlideExperiments experiments) { + registry.register(new DefaultImageHeaderParser()); + // Right now we're only using this parser for HEIF images, which are only supported on OMR1+. + // If we need this for other file types, we should consider removing this restriction. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + registry.register(new ExifInterfaceImageHeaderParser()); + } + + final Resources resources = context.getResources(); + List imageHeaderParsers = registry.getImageHeaderParsers(); + + ByteBufferGifDecoder byteBufferGifDecoder = + new ByteBufferGifDecoder(context, imageHeaderParsers, bitmapPool, arrayPool); + ResourceDecoder parcelFileDescriptorVideoDecoder = + VideoDecoder.parcel(bitmapPool); + + // TODO(judds): Make ParcelFileDescriptorBitmapDecoder work with ImageDecoder. + Downsampler downsampler = + new Downsampler( + registry.getImageHeaderParsers(), resources.getDisplayMetrics(), bitmapPool, arrayPool); + + ResourceDecoder byteBufferBitmapDecoder; + ResourceDecoder streamBitmapDecoder; + ResourceDecoder uriBitmapDecoder = null; + ResourceDecoder fallbackByteBufferBitmapDecoder = null; + ResourceDecoder fallbackStreamBitmapDecoder = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + && experiments.isEnabled(EnableImageDecoderForBitmaps.class)) { + streamBitmapDecoder = + new InputStreamBitmapImageDecoderResourceDecoder( + imageHeaderParsers, + experiments.isEnabled(GlideBuilder.UseHeapBufferForImageDecoderWithInputStream.class), + arrayPool, + experiments.isEnabled( + GlideBuilder.UseArrayPoolForImageDecoderByteBufferAllocation.class)); + byteBufferBitmapDecoder = new ByteBufferBitmapImageDecoderResourceDecoder(); + if (experiments.isEnabled(EnableUriImageDecoder.class)) { + uriBitmapDecoder = new UriBitmapImageDecoderResourceDecoder(context); + } + fallbackByteBufferBitmapDecoder = new ByteBufferBitmapDecoder(downsampler); + fallbackStreamBitmapDecoder = new StreamBitmapDecoder(downsampler, arrayPool); + } else { + byteBufferBitmapDecoder = new ByteBufferBitmapDecoder(downsampler); + streamBitmapDecoder = new StreamBitmapDecoder(downsampler, arrayPool); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + registry.append( + Registry.BUCKET_ANIMATION, + InputStream.class, + Drawable.class, + AnimatedImageDecoder.streamDecoder(imageHeaderParsers, arrayPool)); + registry.append( + Registry.BUCKET_ANIMATION, + ByteBuffer.class, + Drawable.class, + AnimatedImageDecoder.byteBufferDecoder(imageHeaderParsers, arrayPool)); + } + + ResourceDrawableDecoder resourceDrawableDecoder = new ResourceDrawableDecoder(context); + + BitmapEncoder bitmapEncoder = new BitmapEncoder(arrayPool); + + BitmapBytesTranscoder bitmapBytesTranscoder = new BitmapBytesTranscoder(); + GifDrawableBytesTranscoder gifDrawableBytesTranscoder = new GifDrawableBytesTranscoder(); + + ContentResolver contentResolver = context.getContentResolver(); + + registry + .append(ByteBuffer.class, new ByteBufferEncoder()) + .append(InputStream.class, new StreamEncoder(arrayPool)) + /* Bitmaps */ + .append(Registry.BUCKET_BITMAP, ByteBuffer.class, Bitmap.class, byteBufferBitmapDecoder); + if (fallbackByteBufferBitmapDecoder != null) { + registry.append( + Registry.BUCKET_BITMAP, ByteBuffer.class, Bitmap.class, fallbackByteBufferBitmapDecoder); + } + registry.append(Registry.BUCKET_BITMAP, InputStream.class, Bitmap.class, streamBitmapDecoder); + if (fallbackStreamBitmapDecoder != null) { + registry.append( + Registry.BUCKET_BITMAP, InputStream.class, Bitmap.class, fallbackStreamBitmapDecoder); + } + + if (uriBitmapDecoder != null) { + registry.prepend(Uri.class, Bitmap.class, uriBitmapDecoder); + registry.prepend(Uri.class, Uri.class, UnitModelLoader.Factory.getInstance()); + } + + if (ParcelFileDescriptorRewinder.isSupported()) { + registry.append( + Registry.BUCKET_BITMAP, + ParcelFileDescriptor.class, + Bitmap.class, + new ParcelFileDescriptorBitmapDecoder(downsampler)); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + registry.append( + Registry.BUCKET_BITMAP, + AssetFileDescriptor.class, + Bitmap.class, + VideoDecoder.asset(bitmapPool)); + } + + registry + .append( + Registry.BUCKET_BITMAP, + ParcelFileDescriptor.class, + Bitmap.class, + parcelFileDescriptorVideoDecoder) + .append(Bitmap.class, Bitmap.class, UnitModelLoader.Factory.getInstance()) + .append(Registry.BUCKET_BITMAP, Bitmap.class, Bitmap.class, new UnitBitmapDecoder()) + .append(Bitmap.class, bitmapEncoder) + /* BitmapDrawables */ + .append( + Registry.BUCKET_BITMAP_DRAWABLE, + ByteBuffer.class, + BitmapDrawable.class, + new BitmapDrawableDecoder<>(resources, byteBufferBitmapDecoder)) + .append( + Registry.BUCKET_BITMAP_DRAWABLE, + InputStream.class, + BitmapDrawable.class, + new BitmapDrawableDecoder<>(resources, streamBitmapDecoder)) + .append( + Registry.BUCKET_BITMAP_DRAWABLE, + ParcelFileDescriptor.class, + BitmapDrawable.class, + new BitmapDrawableDecoder<>(resources, parcelFileDescriptorVideoDecoder)); + + if (uriBitmapDecoder != null) { + registry.prepend( + Uri.class, + BitmapDrawable.class, + new BitmapDrawableDecoder<>(resources, uriBitmapDecoder)); + } + + registry + .append(BitmapDrawable.class, new BitmapDrawableEncoder(bitmapPool, bitmapEncoder)) + /* GIFs */ + .append( + Registry.BUCKET_ANIMATION, + InputStream.class, + GifDrawable.class, + new StreamGifDecoder(imageHeaderParsers, byteBufferGifDecoder, arrayPool)) + .append( + Registry.BUCKET_ANIMATION, ByteBuffer.class, GifDrawable.class, byteBufferGifDecoder) + .append(GifDrawable.class, new GifDrawableEncoder()) + /* GIF Frames */ + // Compilation with Gradle requires the type to be specified for UnitModelLoader here. + .append( + GifDecoder.class, GifDecoder.class, UnitModelLoader.Factory.getInstance()) + .append( + Registry.BUCKET_BITMAP, + GifDecoder.class, + Bitmap.class, + new GifFrameResourceDecoder(bitmapPool)) + /* Drawables */ + .append(Uri.class, Drawable.class, resourceDrawableDecoder) + .append( + Uri.class, Bitmap.class, new ResourceBitmapDecoder(resourceDrawableDecoder, bitmapPool)) + /* Files */ + .register(new ByteBufferRewinder.Factory()) + .append(File.class, ByteBuffer.class, new ByteBufferFileLoader.Factory()) + .append(File.class, InputStream.class, new FileLoader.StreamFactory()) + .append(File.class, File.class, new FileDecoder()) + .append(File.class, ParcelFileDescriptor.class, new FileLoader.FileDescriptorFactory()) + // Compilation with Gradle requires the type to be specified for UnitModelLoader here. + .append(File.class, File.class, UnitModelLoader.Factory.getInstance()) + /* Models */ + .register(new InputStreamRewinder.Factory(arrayPool)); + + if (ParcelFileDescriptorRewinder.isSupported()) { + registry.register(new ParcelFileDescriptorRewinder.Factory()); + } + + // DirectResourceLoader and ResourceUriLoader handle resource IDs and Uris owned by this + // package. + ModelLoaderFactory directResourceLoaderStreamFactory = + DirectResourceLoader.inputStreamFactory(context); + ModelLoaderFactory + directResourceLoaderAssetFileDescriptorFactory = + DirectResourceLoader.assetFileDescriptorFactory(context); + ModelLoaderFactory directResourceLaoderDrawableFactory = + DirectResourceLoader.drawableFactory(context); + registry + .append(int.class, InputStream.class, directResourceLoaderStreamFactory) + .append(Integer.class, InputStream.class, directResourceLoaderStreamFactory) + .append( + int.class, AssetFileDescriptor.class, directResourceLoaderAssetFileDescriptorFactory) + .append( + Integer.class, + AssetFileDescriptor.class, + directResourceLoaderAssetFileDescriptorFactory) + .append(int.class, Drawable.class, directResourceLaoderDrawableFactory) + .append(Integer.class, Drawable.class, directResourceLaoderDrawableFactory) + .append(Uri.class, InputStream.class, ResourceUriLoader.newStreamFactory(context)) + .append( + Uri.class, + AssetFileDescriptor.class, + ResourceUriLoader.newAssetFileDescriptorFactory(context)); + + // ResourceLoader and UriLoader handle resource IDs and Uris owned by other packages. + ResourceLoader.UriFactory resourceLoaderUriFactory = new ResourceLoader.UriFactory(resources); + ResourceLoader.AssetFileDescriptorFactory resourceLoaderAssetFileDescriptorFactory = + new ResourceLoader.AssetFileDescriptorFactory(resources); + ResourceLoader.StreamFactory resourceLoaderStreamFactory = + new ResourceLoader.StreamFactory(resources); + registry + .append(Integer.class, Uri.class, resourceLoaderUriFactory) + .append(int.class, Uri.class, resourceLoaderUriFactory) + .append(Integer.class, AssetFileDescriptor.class, resourceLoaderAssetFileDescriptorFactory) + .append(int.class, AssetFileDescriptor.class, resourceLoaderAssetFileDescriptorFactory) + .append(Integer.class, InputStream.class, resourceLoaderStreamFactory) + .append(int.class, InputStream.class, resourceLoaderStreamFactory); + + registry + .append(String.class, InputStream.class, new DataUrlLoader.StreamFactory()) + .append(Uri.class, InputStream.class, new DataUrlLoader.StreamFactory()) + .append(String.class, InputStream.class, new StringLoader.StreamFactory()) + .append(String.class, ParcelFileDescriptor.class, new StringLoader.FileDescriptorFactory()) + .append( + String.class, AssetFileDescriptor.class, new StringLoader.AssetFileDescriptorFactory()) + .append(Uri.class, InputStream.class, new AssetUriLoader.StreamFactory(context.getAssets())) + .append( + Uri.class, + AssetFileDescriptor.class, + new AssetUriLoader.FileDescriptorFactory(context.getAssets())) + .append(Uri.class, InputStream.class, new MediaStoreImageThumbLoader.Factory(context)) + .append(Uri.class, InputStream.class, new MediaStoreVideoThumbLoader.Factory(context)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + registry.append( + Uri.class, InputStream.class, new QMediaStoreUriLoader.InputStreamFactory(context)); + registry.append( + Uri.class, + ParcelFileDescriptor.class, + new QMediaStoreUriLoader.FileDescriptorFactory(context)); + } + boolean useMediaStoreOpenFileApisIfPossible = + experiments.isEnabled(UseMediaStoreOpenFileApisIfPossible.class); + registry + .append( + Uri.class, + InputStream.class, + new UriLoader.StreamFactory(contentResolver, useMediaStoreOpenFileApisIfPossible)) + .append( + Uri.class, + ParcelFileDescriptor.class, + new UriLoader.FileDescriptorFactory( + contentResolver, useMediaStoreOpenFileApisIfPossible)) + .append( + Uri.class, + AssetFileDescriptor.class, + new UriLoader.AssetFileDescriptorFactory( + contentResolver, useMediaStoreOpenFileApisIfPossible)) + .append(Uri.class, InputStream.class, new UrlUriLoader.StreamFactory()) + .append(URL.class, InputStream.class, new UrlLoader.StreamFactory()) + .append(Uri.class, File.class, new MediaStoreFileLoader.Factory(context)) + .append(GlideUrl.class, InputStream.class, new HttpGlideUrlLoader.Factory()) + .append(byte[].class, ByteBuffer.class, new ByteArrayLoader.ByteBufferFactory()) + .append(byte[].class, InputStream.class, new ByteArrayLoader.StreamFactory()) + .append(Uri.class, Uri.class, UnitModelLoader.Factory.getInstance()) + .append(Drawable.class, Drawable.class, UnitModelLoader.Factory.getInstance()) + .append(Drawable.class, Drawable.class, new UnitDrawableDecoder()) + /* Transcoders */ + .register(Bitmap.class, BitmapDrawable.class, new BitmapDrawableTranscoder(resources)) + .register(Bitmap.class, byte[].class, bitmapBytesTranscoder) + .register( + Drawable.class, + byte[].class, + new DrawableBytesTranscoder( + bitmapPool, bitmapBytesTranscoder, gifDrawableBytesTranscoder)) + .register(GifDrawable.class, byte[].class, gifDrawableBytesTranscoder); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + ResourceDecoder byteBufferVideoDecoder = + VideoDecoder.byteBuffer(bitmapPool); + registry.append(ByteBuffer.class, Bitmap.class, byteBufferVideoDecoder); + registry.append( + ByteBuffer.class, + BitmapDrawable.class, + new BitmapDrawableDecoder<>(resources, byteBufferVideoDecoder)); + } + } + + private static void initializeModules( + Context context, + Glide glide, + Registry registry, + List manifestModules, + @Nullable AppGlideModule annotationGeneratedModule) { + for (GlideModule module : manifestModules) { + try { + module.registerComponents(context, glide, registry); + } catch (AbstractMethodError e) { + throw new IllegalStateException( + "Attempting to register a Glide v3 module. If you see this, you or one of your" + + " dependencies may be including Glide v3 even though you're using Glide v4." + + " You'll need to find and remove (or update) the offending dependency." + + " The v3 module name is: " + + module.getClass().getName(), + e); + } + } + if (annotationGeneratedModule != null) { + annotationGeneratedModule.registerComponents(context, glide, registry); + } + } +} diff --git a/library/src/main/java/com/bumptech/glide/RequestBuilder.java b/library/src/main/java/com/bumptech/glide/RequestBuilder.java index 366da0bbec..b716250241 100644 --- a/library/src/main/java/com/bumptech/glide/RequestBuilder.java +++ b/library/src/main/java/com/bumptech/glide/RequestBuilder.java @@ -1,11 +1,12 @@ package com.bumptech.glide; import static com.bumptech.glide.request.RequestOptions.diskCacheStrategyOf; -import static com.bumptech.glide.request.RequestOptions.signatureOf; import static com.bumptech.glide.request.RequestOptions.skipMemoryCacheOf; import android.annotation.SuppressLint; +import android.content.ContentResolver; import android.content.Context; +import android.content.res.Resources.Theme; import android.graphics.Bitmap; import android.graphics.drawable.Drawable; import android.net.Uri; @@ -15,8 +16,10 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RawRes; +import com.bumptech.glide.load.DataSource; import com.bumptech.glide.load.Transformation; import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.engine.GlideException; import com.bumptech.glide.request.BaseRequestOptions; import com.bumptech.glide.request.ErrorRequestCoordinator; import com.bumptech.glide.request.FutureTarget; @@ -30,16 +33,17 @@ import com.bumptech.glide.request.target.PreloadTarget; import com.bumptech.glide.request.target.Target; import com.bumptech.glide.request.target.ViewTarget; +import com.bumptech.glide.request.transition.Transition; import com.bumptech.glide.signature.AndroidResourceSignature; import com.bumptech.glide.util.Executors; import com.bumptech.glide.util.Preconditions; -import com.bumptech.glide.util.Synthetic; import com.bumptech.glide.util.Util; import java.io.File; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.concurrent.Executor; /** @@ -78,11 +82,12 @@ public class RequestBuilder extends BaseRequestOptions transcodeClass, RequestBuilder other) { this(other.glide, other.requestManager, transcodeClass, other.context); model = other.model; @@ -161,12 +170,11 @@ public RequestBuilder transition( } /** - * Sets a {@link RequestListener} to monitor the resource load. It's best to create a single - * instance of an exception handler per type of request (usually activity/fragment) rather than - * pass one in per request to avoid some redundant object allocation. + * Sets a {@link RequestListener} to monitor the resource load and removes all previously set + * listeners (either via this method or from {@link #addListener(RequestListener)} . * - *

Subsequent calls to this method will replace previously set listeners. To set multiple - * listeners, use {@link #addListener} instead. + *

Calls to this method will replace previously set listeners. To set multiple listeners, use + * {@link #addListener} instead. * * @param requestListener The request listener to use. * @return This request builder. @@ -184,8 +192,68 @@ public RequestBuilder listener( } /** - * Adds a {@link RequestListener}. If called multiple times, all passed {@link RequestListener - * listeners} will be called in order. + * Adds a {@link RequestListener} to the list that will be called in the order they were added + * when the request ends. + * + *

Multiple calls to this method append additional listeners. Previous listeners are not + * removed. If you want to replace any previously added listeners, use {@link + * #listener(RequestListener)}. + * + *

Listeners track the state of the request started by this particular {@code builder}. When + * used with the thumbnail APIs ({@link #thumbnail(RequestBuilder)}) this can start to seem + * confusing because multiple requests are running and each may succeed or fail, independent of + * each other. As a rule, Glide does not add {@link RequestListener}s to thumbnail requests + * automatically. That means that {@link RequestListener}s track the state of exactly one request + * in the chain. For example, if you start a primary request with a single nested thumbnail and + * you add a {@link RequestListener} only to the primary request, then the {@link RequestListener} + * will only be notified when the primary request succeeds or fails. If the thumbnail succeeds, + * but the primary request fails, the {@link RequestListener} added to the primary request will + * still be called with {@link RequestListener#onLoadFailed(GlideException, Object, Target, + * boolean)}. In the same scenario, the {@link RequestListener} added only to the primary request + * will not have {@link RequestListener#onResourceReady(Object, Object, Target, DataSource, + * boolean)} called when the thumbnail request finishes successfully. Similarly, if you add a + * {@link RequestListener} only to a thumbnail request, but not the primary request, that {@code + * listener} will only be called for changes related to the thumbnail request. If the thumbnail + * request fails, the {@code listener} added to the thumbnail request will be immediately called + * via {@link RequestListener#onLoadFailed(GlideException, Object, Target, boolean)}, even though + * the primary request may eventually succeed. It is perfectly possible to add a {@link + * RequestListener} to both the primary and a thumbnail request. If you do so, the {@link + * RequestListener} will be called independently for each request when it finishes. Keep in mind + * that if any parent request finishes before its thumbnail request(s), it will attempt to cancel + * those requests. As a result there's no guarantee that a {@link RequestListener} added to a + * thumbnail request will actually be called with either success or failure. These same patterns + * hold for arbitrarily nested thumbnails. The {@code listener} is only called for the requests it + * is added to and may not be called for every thumbnail request if those requests are cancelled + * due to the completion of a parent request. + * + *

The one exception to the rules about thumbnails is {@link #thumbnail(float)}. In this case + * we appear to be passing {@link RequestListener}s added to the parent request to the generated + * thumbnail requests. To try to reduce confusion, the {@link #thumbnail(float)} method has been + * deprecated. It can be easily replicated using {@link #thumbnail(RequestBuilder)} and {@link + * BaseRequestOptions#sizeMultiplier(float)}. + * + *

Often in UIs it's desirable to try to track the overall status of a request, including the + * thumbnails. For example, you might want to load an image, start an animation if the + * asynchronous image load succeeds and perform some fallback action if it fails. If you're using + * a single primary request, {@link RequestListener} will work for this. However, if you then + * decide to try to make things more performant by adding a thumbnail (or multiple thumbnails), + * {@link RequestListener} is awkward because either you only add it to the main request and it's + * not called when the thumbnails complete (which defeats the purpose) or it's called for every + * request and it's hard to keep track of when the overall request has failed. A better option + * than using {@link RequestListener} to track the state of the UI then is to use {@link Target} + * instead. {@link Target#onResourceReady(Object, Transition)} will be called when any thumbnail + * finishes, which you can use to trigger your animation starting. {@link + * Target#onLoadFailed(Drawable)} will only be called if every request in the chain, including the + * primary request, fails, which you can use to trigger your fallback behavior. Be sure to pick an + * appropriate {@link Target} subclass when possible, like {@link + * com.bumptech.glide.request.target.BitmapImageViewTarget} or {@link + * com.bumptech.glide.request.target.DrawableImageViewTarget} when loading into {@link ImageView} + * or {@link com.bumptech.glide.request.target.CustomTarget} when using custom rendering. Don't + * forget to call {@code super()} in the {@code ImageViewTarget}s. + * + *

It's best to create a single instance of an exception handler per type of request (usually + * activity/fragment) rather than pass one in per request to avoid some redundant object + * allocation. * * @param requestListener The request listener to use. If {@code null}, this method is a noop. * @return This request builder. @@ -423,10 +491,21 @@ public RequestBuilder thumbnail( * @param sizeMultiplier The multiplier to apply to the {@link Target}'s dimensions when loading * the thumbnail. * @return This request builder. + * @deprecated The behavior differences between this method and {@link #thumbnail(RequestBuilder)} + * are subtle, hard to understand for users and hard to maintain for developers. See the + * javadoc on {@link #listener(RequestListener)} for one concrete example of the behavior + * differences and complexity introduced by this method. Better consistency and readability + * can be obtained by calling {@link #thumbnail(RequestBuilder)} with a duplicate {@code + * RequestBuilder} on which you have called {@link BaseRequestOptions#sizeMultiplier(float)}. + * In practice this method also isn't especially useful. It's much more common to want to + * specify a number of different attributes for thumbnails than just a simple percentage + * modifier on the target size, so there's little justification for keeping this method. This + * method will be removed in a future version of Glide. */ @NonNull @CheckResult @SuppressWarnings("unchecked") + @Deprecated public RequestBuilder thumbnail(float sizeMultiplier) { if (isAutoCloneEnabled()) { return clone().thumbnail(sizeMultiplier); @@ -462,6 +541,7 @@ private RequestBuilder loadGeneric(@Nullable Object model) { isModelSet = true; return selfOrThrowIfLocked(); } + /** * Returns an object to load the given {@link Bitmap}. * @@ -525,6 +605,11 @@ public RequestBuilder load(@Nullable Drawable drawable) { * com.bumptech.glide.load.engine.DiskCacheStrategy#NONE} and/or {@link * com.bumptech.glide.request.RequestOptions#skipMemoryCache(boolean)} may be appropriate. * + *

If {@code string} is in fact a resource {@link Uri}, you should first parse it to a Uri + * using {@link Uri#parse(String)} and then pass the {@code Uri} to {@link #load(Uri)}. Doing so + * will ensure that we respect the appropriate theme / dark / light mode. As an alternative, you + * can also manually apply the current {@link Theme} using {@link #theme(Theme)}. + * * @see #load(Object) * @param string A file path, or a uri or url handled by {@link * com.bumptech.glide.load.model.UriLoader}. @@ -546,7 +631,20 @@ public RequestBuilder load(@Nullable String string) { * signature you create based on the data at the given Uri that will invalidate the cache if that * data changes. Alternatively, using {@link * com.bumptech.glide.load.engine.DiskCacheStrategy#NONE} and/or {@link - * com.bumptech.glide.request.RequestOptions#skipMemoryCache(boolean)} may be appropriate. + * com.bumptech.glide.request.RequestOptions#skipMemoryCache(boolean)} may be appropriate. The + * only exception to this is that if we recognize the given {@code uri} as having {@link + * ContentResolver#SCHEME_ANDROID_RESOURCE}, then we'll apply {@link AndroidResourceSignature} + * automatically. If we do so, calls to other {@code load()} methods will not override + * the automatically applied signature. + * + *

If {@code uri} has a {@link Uri#getScheme()} of {@link + * android.content.ContentResolver#SCHEME_ANDROID_RESOURCE}, then this method will add the {@link + * android.content.res.Resources.Theme} of the {@link Context} associated with this {@code + * requestBuilder} so that we can respect themeable attributes and/or light / dark mode. Any call + * to {@link #theme(Theme)} prior to this method call will be overridden. To avoid this, call + * {@link #theme(Theme)} after calling this method with either {@code null} or the {@code Theme} + * you'd prefer to use instead. Note that even if you change the theme, the {@link + * AndroidResourceSignature} will still be based on the {@link Context} theme. * * @see #load(Object) * @param uri The Uri representing the image. Must be of a type handled by {@link @@ -556,7 +654,22 @@ public RequestBuilder load(@Nullable String string) { @CheckResult @Override public RequestBuilder load(@Nullable Uri uri) { - return loadGeneric(uri); + return maybeApplyOptionsResourceUri(uri, loadGeneric(uri)); + } + + private RequestBuilder maybeApplyOptionsResourceUri( + @Nullable Uri uri, RequestBuilder requestBuilder) { + if (uri == null || !ContentResolver.SCHEME_ANDROID_RESOURCE.equals(uri.getScheme())) { + return requestBuilder; + } + return applyResourceThemeAndSignature(requestBuilder); + } + + private RequestBuilder applyResourceThemeAndSignature( + RequestBuilder requestBuilder) { + return requestBuilder + .theme(context.getTheme()) + .signature(AndroidResourceSignature.obtain(context)); } /** @@ -610,6 +723,13 @@ public RequestBuilder load(@Nullable File file) { * method, especially in conjunction with {@link com.bumptech.glide.load.Transformation}s with * caution for non-{@link Bitmap} {@link Drawable}s. * + *

This method will add the {@link android.content.res.Resources.Theme} of the {@link Context} + * associated with this {@code requestBuilder} so that we can respect themeable attributes and/or + * light / dark mode. Any call to {@link #theme(Theme)} prior to this method call will be + * overridden. To avoid this, call {@link #theme(Theme)} after calling this method with either + * {@code null} or the {@code Theme} you'd prefer to use instead. Note that even if you change the + * theme, the {@link AndroidResourceSignature} will still be based on the {@link Context} theme. + * * @see #load(Integer) * @see com.bumptech.glide.signature.AndroidResourceSignature */ @@ -617,7 +737,7 @@ public RequestBuilder load(@Nullable File file) { @CheckResult @Override public RequestBuilder load(@RawRes @DrawableRes @Nullable Integer resourceId) { - return loadGeneric(resourceId).apply(signatureOf(AndroidResourceSignature.obtain(context))); + return applyResourceThemeAndSignature(loadGeneric(resourceId)); } /** @@ -695,16 +815,28 @@ public RequestBuilder clone() { */ @NonNull public > Y into(@NonNull Y target) { - return into(target, /*targetListener=*/ null, Executors.mainThreadExecutor()); + return into(target, /* targetListener= */ null, Executors.mainThreadExecutor()); } + /** + * Set the target the resource will be loaded into; the callback will be set at the front of the + * queue. + * + * @param target The target to load the resource into. + * @return The given target. + * @see RequestManager#clear(Target) + */ @NonNull - @Synthetic - > Y into( + public > Y experimentalIntoFront(@NonNull Y target) { + return into(target, /* targetListener= */ null, Executors.mainThreadExecutorFront()); + } + + @NonNull + public > Y into( @NonNull Y target, @Nullable RequestListener targetListener, Executor callbackExecutor) { - return into(target, targetListener, /*options=*/ this, callbackExecutor); + return into(target, targetListener, /* options= */ this, callbackExecutor); } private > Y into( @@ -798,11 +930,62 @@ public ViewTarget into(@NonNull ImageView view) { return into( glideContext.buildImageViewTarget(view, transcodeClass), - /*targetListener=*/ null, + /* targetListener= */ null, requestOptions, Executors.mainThreadExecutor()); } + /** + * Sets the {@link ImageView} the resource will be loaded into, cancels any existing loads into + * the view, and frees any resources Glide may have previously loaded into the view so they may be + * reused; the callback will be set at the front of the queue. + * + * @see RequestManager#clear(Target) + * @param view The view to cancel previous loads for and load the new resource into. + * @return The {@link com.bumptech.glide.request.target.Target} used to wrap the given {@link + * ImageView}. + */ + @NonNull + public ViewTarget experimentalIntoFront(@NonNull ImageView view) { + Util.assertMainThread(); + Preconditions.checkNotNull(view); + + BaseRequestOptions requestOptions = this; + if (!requestOptions.isTransformationSet() + && requestOptions.isTransformationAllowed() + && view.getScaleType() != null) { + // Clone in this method so that if we use this RequestBuilder to load into a View and then + // into a different target, we don't retain the transformation applied based on the previous + // View's scale type. + switch (view.getScaleType()) { + case CENTER_CROP: + requestOptions = requestOptions.clone().optionalCenterCrop(); + break; + case CENTER_INSIDE: + requestOptions = requestOptions.clone().optionalCenterInside(); + break; + case FIT_CENTER: + case FIT_START: + case FIT_END: + requestOptions = requestOptions.clone().optionalFitCenter(); + break; + case FIT_XY: + requestOptions = requestOptions.clone().optionalCenterInside(); + break; + case CENTER: + case MATRIX: + default: + // Do nothing. + } + } + + return into( + glideContext.buildImageViewTarget(view, transcodeClass), + /* targetListener= */ null, + requestOptions, + Executors.mainThreadExecutorFront()); + } + /** * Returns a future that can be used to do a blocking get on a background thread. * @@ -859,6 +1042,13 @@ public FutureTarget submit(int width, int height) { *

Pre-loading is useful for making sure that resources you are going to to want in the near * future are available quickly. * + *

Note - Any thumbnail request that does not complete before the primary request will be + * cancelled and may not be preloaded successfully. Cancellation of outstanding thumbnails after + * the primary request succeeds is a common behavior of all Glide requests. We do not try to + * prevent that behavior here. If you absolutely need all thumbnails to be preloaded individually, + * make separate preload() requests for each thumbnail (you can still combine them into one call + * when loading the image(s) into the UI in a subsequent request). + * * @param width The desired width in pixels, or {@link Target#SIZE_ORIGINAL}. This will be * overridden by {@link com.bumptech.glide.request.RequestOptions#override(int, int)} if * previously called. @@ -875,6 +1065,36 @@ public Target preload(int width, int height) { return into(target); } + /** + * Preloads the resource into the cache using the given width and height; the callback will be set + * at the front of the queue. + * + *

Pre-loading is useful for making sure that resources you are going to to want in the near + * future are available quickly. + * + *

Note - Any thumbnail request that does not complete before the primary request will be + * cancelled and may not be preloaded successfully. Cancellation of outstanding thumbnails after + * the primary request succeeds is a common behavior of all Glide requests. We do not try to + * prevent that behavior here. If you absolutely need all thumbnails to be preloaded individually, + * make separate preload() requests for each thumbnail (you can still combine them into one call + * when loading the image(s) into the UI in a subsequent request). + * + * @param width The desired width in pixels, or {@link Target#SIZE_ORIGINAL}. This will be + * overridden by {@link com.bumptech.glide.request.RequestOptions#override(int, int)} if + * previously called. + * @param height The desired height in pixels, or {@link Target#SIZE_ORIGINAL}. This will be + * overridden by {@link com.bumptech.glide.request.RequestOptions#override(int, int)}} if + * previously called). + * @return A {@link Target} that can be used to cancel the load via {@link + * RequestManager#clear(Target)}. + * @see com.bumptech.glide.ListPreloader + */ + @NonNull + public Target experimentalPreloadFront(int width, int height) { + final PreloadTarget target = PreloadTarget.obtain(requestManager, width, height); + return experimentalIntoFront(target); + } + /** * Preloads the resource into the cache using {@link Target#SIZE_ORIGINAL} as the target width and * height. Equivalent to calling {@link #preload(int, int)} with {@link Target#SIZE_ORIGINAL} as @@ -947,10 +1167,10 @@ private Request buildRequest( BaseRequestOptions requestOptions, Executor callbackExecutor) { return buildRequestRecursive( - /*requestLock=*/ new Object(), + /* requestLock= */ new Object(), target, targetListener, - /*parentCoordinator=*/ null, + /* parentCoordinator= */ null, transitionOptions, requestOptions.getPriority(), requestOptions.getOverrideWidth(), @@ -1169,4 +1389,41 @@ private Request obtainRequest( transitionOptions.getTransitionFactory(), callbackExecutor); } + + Object getModel() { + return model; + } + + @Override + public boolean equals(Object o) { + if (o instanceof RequestBuilder) { + RequestBuilder that = (RequestBuilder) o; + return super.equals(that) + && Objects.equals(transcodeClass, that.transcodeClass) + && transitionOptions.equals(that.transitionOptions) + && Objects.equals(model, that.model) + && Objects.equals(requestListeners, that.requestListeners) + && Objects.equals(thumbnailBuilder, that.thumbnailBuilder) + && Objects.equals(errorBuilder, that.errorBuilder) + && Objects.equals(thumbSizeMultiplier, that.thumbSizeMultiplier) + && isDefaultTransitionOptionsSet == that.isDefaultTransitionOptionsSet + && isModelSet == that.isModelSet; + } + return false; + } + + @Override + public int hashCode() { + int hashCode = super.hashCode(); + hashCode = Util.hashCode(transcodeClass, hashCode); + hashCode = Util.hashCode(transitionOptions, hashCode); + hashCode = Util.hashCode(model, hashCode); + hashCode = Util.hashCode(requestListeners, hashCode); + hashCode = Util.hashCode(thumbnailBuilder, hashCode); + hashCode = Util.hashCode(errorBuilder, hashCode); + hashCode = Util.hashCode(thumbSizeMultiplier, hashCode); + hashCode = Util.hashCode(isDefaultTransitionOptionsSet, hashCode); + hashCode = Util.hashCode(isModelSet, hashCode); + return hashCode; + } } diff --git a/library/src/main/java/com/bumptech/glide/RequestManager.java b/library/src/main/java/com/bumptech/glide/RequestManager.java index a4d2319788..741addcf41 100644 --- a/library/src/main/java/com/bumptech/glide/RequestManager.java +++ b/library/src/main/java/com/bumptech/glide/RequestManager.java @@ -95,6 +95,9 @@ public void run() { private boolean pauseAllRequestsOnTrimMemoryModerate; + private boolean clearOnStop; + + @SuppressWarnings("this-escape") public RequestManager( @NonNull Glide glide, @NonNull Lifecycle lifecycle, @@ -110,7 +113,7 @@ public RequestManager( } // Our usage is safe here. - @SuppressWarnings("PMD.ConstructorCallsOverridableMethod") + @SuppressWarnings({"PMD.ConstructorCallsOverridableMethod", "this-escape"}) RequestManager( Glide glide, Lifecycle lifecycle, @@ -129,6 +132,10 @@ public RequestManager( context.getApplicationContext(), new RequestManagerConnectivityListener(requestTracker)); + // Order matters, this might be unregistered by teh listeners below, so we need to be sure to + // register first to prevent both assertions and memory leaks. + glide.registerRequestManager(this); + // If we're the application level request manager, we may be created on a background thread. // In that case we cannot risk synchronously pausing or resuming requests, so we hack around the // issue by delaying adding ourselves as a lifecycle listener by posting to the main thread. @@ -143,8 +150,6 @@ public RequestManager( defaultRequestListeners = new CopyOnWriteArrayList<>(glide.getGlideContext().getDefaultRequestListeners()); setRequestOptions(glide.getGlideContext().getDefaultRequestOptions()); - - glide.registerRequestManager(this); } protected synchronized void setRequestOptions(@NonNull RequestOptions toSet) { @@ -201,6 +206,17 @@ public synchronized RequestManager setDefaultRequestOptions( return this; } + /** + * Clear all resources when onStop() from {@link LifecycleListener} is called. + * + * @return This request manager. + */ + @NonNull + public synchronized RequestManager clearOnStop() { + clearOnStop = true; + return this; + } + /** * Adds a default {@link RequestListener} that will be added to every request started with this * {@link RequestManager}. @@ -352,12 +368,17 @@ public synchronized void onStart() { /** * Lifecycle callback that unregisters for connectivity events (if the - * android.permission.ACCESS_NETWORK_STATE permission is present) and pauses in progress loads. + * android.permission.ACCESS_NETWORK_STATE permission is present) and pauses in progress loads and + * clears all resources if {@link #clearOnStop()} is called. */ @Override public synchronized void onStop() { - pauseRequests(); targetTracker.onStop(); + if (clearOnStop) { + clearRequests(); + } else { + pauseRequests(); + } } /** @@ -367,10 +388,7 @@ public synchronized void onStop() { @Override public synchronized void onDestroy() { targetTracker.onDestroy(); - for (Target target : targetTracker.getAll()) { - clear(target); - } - targetTracker.clear(); + clearRequests(); requestTracker.clearRequests(); lifecycle.removeListener(this); lifecycle.removeListener(connectivityMonitor); @@ -701,6 +719,13 @@ public void onLowMemory() { // Nothing to add conditionally. See Glide#onTrimMemory for unconditional behavior. } + private synchronized void clearRequests() { + for (Target target : targetTracker.getAll()) { + clear(target); + } + targetTracker.clear(); + } + @Override public void onConfigurationChanged(Configuration newConfig) {} diff --git a/library/src/main/java/com/bumptech/glide/TransitionOptions.java b/library/src/main/java/com/bumptech/glide/TransitionOptions.java index 0132cd6994..c8f4626609 100644 --- a/library/src/main/java/com/bumptech/glide/TransitionOptions.java +++ b/library/src/main/java/com/bumptech/glide/TransitionOptions.java @@ -7,10 +7,13 @@ import com.bumptech.glide.request.transition.ViewPropertyAnimationFactory; import com.bumptech.glide.request.transition.ViewPropertyTransition; import com.bumptech.glide.util.Preconditions; +import com.bumptech.glide.util.Util; /** * A base class for setting a transition to use on a resource when a load completes. * + *

Note: Implementations must implement equals/hashcode. + * * @param The implementation of this class to return to chain methods. * @param The type of resource that will be animated. */ @@ -35,8 +38,8 @@ public final CHILD dontTransition() { * load finishes. Will only be run if the resource was loaded asynchronously (i.e. was not in the * memory cache). * - * @param viewAnimationId The resource id of the {@link android.view.animation} to use as the - * transition. + * @param viewAnimationId The resource id of the {@link android.view.animation.Animation} to use + * as the transition. * @return This request builder. */ @NonNull @@ -97,4 +100,18 @@ final TransitionFactory getTransitionFactory() { private CHILD self() { return (CHILD) this; } + + @Override + public boolean equals(Object o) { + if (o instanceof TransitionOptions) { + TransitionOptions other = (TransitionOptions) o; + return Util.bothNullOrEqual(transitionFactory, other.transitionFactory); + } + return false; + } + + @Override + public int hashCode() { + return transitionFactory != null ? transitionFactory.hashCode() : 0; + } } diff --git a/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java b/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java index ce3606626d..76fbf4fd01 100644 --- a/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java +++ b/library/src/main/java/com/bumptech/glide/load/ImageHeaderParser.java @@ -30,6 +30,16 @@ enum ImageType { WEBP_A(true), /** WebP type without alpha. */ WEBP(false), + /** All animated webps. */ + ANIMATED_WEBP(true), + /** Avif type (may contain alpha). */ + AVIF(true), + /** Animated Avif type (may contain alpha). */ + ANIMATED_AVIF(true), + /** HEIF type (may contain alpha). */ + HEIF(true), + /** Animated HEIF type (may contain alpha). */ + ANIMATED_HEIF(true), /** Unrecognized type. */ UNKNOWN(false); @@ -42,6 +52,17 @@ enum ImageType { public boolean hasAlpha() { return hasAlpha; } + + public boolean isWebp() { + switch (this) { + case WEBP: + case WEBP_A: + case ANIMATED_WEBP: + return true; + default: + return false; + } + } } @NonNull @@ -61,4 +82,17 @@ public boolean hasAlpha() { int getOrientation(@NonNull ByteBuffer byteBuffer, @NonNull ArrayPool byteArrayPool) throws IOException; + + /** + * Returns whether the {@link InputStream} has associated multi-picture-format (MPF) data. Only + * JPEGs have MPF data. + */ + boolean hasJpegMpf(@NonNull InputStream is, @NonNull ArrayPool byteArrayPool) throws IOException; + + /** + * Returns whether the {@link ByteBuffer} has associated multi-picture-format (MPF) data. Only + * JPEGs have MPF data. + */ + boolean hasJpegMpf(@NonNull ByteBuffer byteBuffer, @NonNull ArrayPool byteArrayPool) + throws IOException; } diff --git a/library/src/main/java/com/bumptech/glide/load/ImageHeaderParserUtils.java b/library/src/main/java/com/bumptech/glide/load/ImageHeaderParserUtils.java index 96c71cb765..d1d821efcb 100644 --- a/library/src/main/java/com/bumptech/glide/load/ImageHeaderParserUtils.java +++ b/library/src/main/java/com/bumptech/glide/load/ImageHeaderParserUtils.java @@ -8,6 +8,7 @@ import com.bumptech.glide.load.data.ParcelFileDescriptorRewinder; import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; import com.bumptech.glide.load.resource.bitmap.RecyclableBufferedInputStream; +import com.bumptech.glide.util.ByteBufferUtil; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; @@ -43,7 +44,7 @@ public static ImageType getType( parsers, new TypeReader() { @Override - public ImageType getType(ImageHeaderParser parser) throws IOException { + public ImageType getTypeAndRewind(ImageHeaderParser parser) throws IOException { try { return parser.getType(finalIs); } finally { @@ -66,8 +67,12 @@ public static ImageType getType( parsers, new TypeReader() { @Override - public ImageType getType(ImageHeaderParser parser) throws IOException { - return parser.getType(buffer); + public ImageType getTypeAndRewind(ImageHeaderParser parser) throws IOException { + try { + return parser.getType(buffer); + } finally { + ByteBufferUtil.rewind(buffer); + } } }); } @@ -83,10 +88,10 @@ public static ImageType getType( parsers, new TypeReader() { @Override - public ImageType getType(ImageHeaderParser parser) throws IOException { + public ImageType getTypeAndRewind(ImageHeaderParser parser) throws IOException { // Wrap the FileInputStream into a RecyclableBufferedInputStream to optimize I/O // performance - InputStream is = null; + RecyclableBufferedInputStream is = null; try { is = new RecyclableBufferedInputStream( @@ -95,12 +100,11 @@ public ImageType getType(ImageHeaderParser parser) throws IOException { byteArrayPool); return parser.getType(is); } finally { - try { - if (is != null) { - is.close(); - } - } catch (IOException e) { - // Ignored. + // If we close the stream, we'll close the file descriptor as well, so we can't do + // that. We do however want to make sure we release any buffers we used back to the + // pool so we call release instead of close. + if (is != null) { + is.release(); } parcelFileDescriptorRewinder.rewindAndGet(); } @@ -114,7 +118,7 @@ private static ImageType getTypeInternal( //noinspection ForLoopReplaceableByForEach to improve perf for (int i = 0, size = parsers.size(); i < size; i++) { ImageHeaderParser parser = parsers.get(i); - ImageType type = reader.getType(parser); + ImageType type = reader.getTypeAndRewind(parser); if (type != ImageType.UNKNOWN) { return type; } @@ -143,8 +147,12 @@ public static int getOrientation( parsers, new OrientationReader() { @Override - public int getOrientation(ImageHeaderParser parser) throws IOException { - return parser.getOrientation(buffer, arrayPool); + public int getOrientationAndRewind(ImageHeaderParser parser) throws IOException { + try { + return parser.getOrientation(buffer, arrayPool); + } finally { + ByteBufferUtil.rewind(buffer); + } } }); } @@ -169,7 +177,7 @@ public static int getOrientation( parsers, new OrientationReader() { @Override - public int getOrientation(ImageHeaderParser parser) throws IOException { + public int getOrientationAndRewind(ImageHeaderParser parser) throws IOException { try { return parser.getOrientation(finalIs, byteArrayPool); } finally { @@ -189,10 +197,10 @@ public static int getOrientation( parsers, new OrientationReader() { @Override - public int getOrientation(ImageHeaderParser parser) throws IOException { + public int getOrientationAndRewind(ImageHeaderParser parser) throws IOException { // Wrap the FileInputStream into a RecyclableBufferedInputStream to optimize I/O // performance - InputStream is = null; + RecyclableBufferedInputStream is = null; try { is = new RecyclableBufferedInputStream( @@ -201,12 +209,11 @@ public int getOrientation(ImageHeaderParser parser) throws IOException { byteArrayPool); return parser.getOrientation(is, byteArrayPool); } finally { - try { - if (is != null) { - is.close(); - } - } catch (IOException e) { - // Ignored. + // If we close the stream, we'll close the file descriptor as well, so we can't do + // that. We do however want to make sure we release any buffers we used back to the + // pool so we call release instead of close. + if (is != null) { + is.release(); } parcelFileDescriptorRewinder.rewindAndGet(); } @@ -219,7 +226,7 @@ private static int getOrientationInternal( //noinspection ForLoopReplaceableByForEach to improve perf for (int i = 0, size = parsers.size(); i < size; i++) { ImageHeaderParser parser = parsers.get(i); - int orientation = reader.getOrientation(parser); + int orientation = reader.getOrientationAndRewind(parser); if (orientation != ImageHeaderParser.UNKNOWN_ORIENTATION) { return orientation; } @@ -228,11 +235,130 @@ private static int getOrientationInternal( return ImageHeaderParser.UNKNOWN_ORIENTATION; } + /** + * Returns the result from the first of {@code parsers} that returns true when MPF is detected, if + * any.. + * + *

If {@code buffer} is null, the parsers list is empty, or none of the parsers returns a valid + * value, false is returned. + */ + public static boolean hasJpegMpf( + @NonNull List parsers, + @Nullable final ByteBuffer buffer, + @NonNull ArrayPool byteArrayPool) + throws IOException { + if (buffer == null) { + return false; + } + + return hasJpegMpfInternal( + parsers, + new JpegMpfReader() { + @Override + public boolean getHasJpegMpfAndRewind(ImageHeaderParser parser) throws IOException { + try { + return parser.hasJpegMpf(buffer, byteArrayPool); + } finally { + ByteBufferUtil.rewind(buffer); + } + } + }); + } + + /** Returns whether the given {@link InputStream} references MPF. */ + public static boolean hasJpegMpf( + @NonNull List parsers, + @Nullable InputStream is, + @NonNull final ArrayPool byteArrayPool) + throws IOException { + if (is == null) { + return false; + } + + if (!is.markSupported()) { + is = new RecyclableBufferedInputStream(is, byteArrayPool); + } + + is.mark(MARK_READ_LIMIT); + final InputStream finalIs = is; + return hasJpegMpfInternal( + parsers, + new JpegMpfReader() { + @Override + public boolean getHasJpegMpfAndRewind(ImageHeaderParser parser) throws IOException { + try { + return parser.hasJpegMpf(finalIs, byteArrayPool); + } finally { + finalIs.reset(); + } + } + }); + } + + /** Returns whether the given {@link ParcelFileDescriptorRewinder} references MPF. */ + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + public static boolean hasJpegMpf( + @NonNull List parsers, + @NonNull final ParcelFileDescriptorRewinder parcelFileDescriptorRewinder, + @NonNull final ArrayPool byteArrayPool) + throws IOException { + return hasJpegMpfInternal( + parsers, + new JpegMpfReader() { + @Override + public boolean getHasJpegMpfAndRewind(ImageHeaderParser parser) throws IOException { + // Wrap the FileInputStream into a RecyclableBufferedInputStream to optimize I/O + // performance + RecyclableBufferedInputStream is = null; + try { + is = + new RecyclableBufferedInputStream( + new FileInputStream( + parcelFileDescriptorRewinder.rewindAndGet().getFileDescriptor()), + byteArrayPool); + return parser.hasJpegMpf(is, byteArrayPool); + } finally { + // If we close the stream, we'll close the file descriptor as well, so we can't do + // that. We do however want to make sure we release any buffers we used back to the + // pool so we call release instead of close. + if (is != null) { + is.release(); + } + parcelFileDescriptorRewinder.rewindAndGet(); + } + } + }); + } + + private static boolean hasJpegMpfInternal( + @NonNull List parsers, JpegMpfReader reader) throws IOException { + //noinspection ForLoopReplaceableByForEach to improve perf + for (int i = 0, size = parsers.size(); i < size; i++) { + ImageHeaderParser parser = parsers.get(i); + if (reader.getHasJpegMpfAndRewind(parser)) { + return true; + } + } + + return false; + } + private interface TypeReader { - ImageType getType(ImageHeaderParser parser) throws IOException; + ImageType getTypeAndRewind(ImageHeaderParser parser) throws IOException; } private interface OrientationReader { - int getOrientation(ImageHeaderParser parser) throws IOException; + int getOrientationAndRewind(ImageHeaderParser parser) throws IOException; + } + + /** Reads JPEG multi-picture format (MPF) data. */ + private interface JpegMpfReader { + + /** + * Returns whether the image is JPEG and has MPF data. + * + *

The parser is guaranteed to be rewound upon termination of the method. + */ + boolean getHasJpegMpfAndRewind(ImageHeaderParser parser) throws IOException; } } diff --git a/library/src/main/java/com/bumptech/glide/load/Options.java b/library/src/main/java/com/bumptech/glide/load/Options.java index 281470acd1..0212d90428 100644 --- a/library/src/main/java/com/bumptech/glide/load/Options.java +++ b/library/src/main/java/com/bumptech/glide/load/Options.java @@ -21,6 +21,13 @@ public Options set(@NonNull Option option, @NonNull T value) { return this; } + // TODO(b/234614365): Expand usage of this method in BaseRequestOptions so that it's usable for + // other options. + public Options remove(@NonNull Option option) { + values.remove(option); + return this; + } + @Nullable @SuppressWarnings("unchecked") public T get(@NonNull Option option) { diff --git a/library/src/main/java/com/bumptech/glide/load/PreferredColorSpace.java b/library/src/main/java/com/bumptech/glide/load/PreferredColorSpace.java index ff346ef6c9..87e481fd4c 100644 --- a/library/src/main/java/com/bumptech/glide/load/PreferredColorSpace.java +++ b/library/src/main/java/com/bumptech/glide/load/PreferredColorSpace.java @@ -1,7 +1,7 @@ package com.bumptech.glide.load; /** - * Glide's supported handling of color spaces on Android O+, defaults to {@link #SRGB}. + * Glide's supported handling of color spaces on Android O+, defaults to null. * *

On Android O, Glide will always request SRGB and will ignore this option if set. A bug on * Android O prevents P3 images from being compressed correctly and can result in color distortion. diff --git a/library/src/main/java/com/bumptech/glide/load/data/AssetFileDescriptorLocalUriFetcher.java b/library/src/main/java/com/bumptech/glide/load/data/AssetFileDescriptorLocalUriFetcher.java index 1667249ced..e65f099195 100644 --- a/library/src/main/java/com/bumptech/glide/load/data/AssetFileDescriptorLocalUriFetcher.java +++ b/library/src/main/java/com/bumptech/glide/load/data/AssetFileDescriptorLocalUriFetcher.java @@ -14,10 +14,19 @@ public AssetFileDescriptorLocalUriFetcher(ContentResolver contentResolver, Uri u super(contentResolver, uri); } + /** + * useMediaStoreApisIfAvailable is part of an experiment and the constructor can be removed in a + * future version. + */ + public AssetFileDescriptorLocalUriFetcher( + ContentResolver contentResolver, Uri uri, boolean useMediaStoreApisIfAvailable) { + super(contentResolver, uri, useMediaStoreApisIfAvailable); + } + @Override protected AssetFileDescriptor loadResource(Uri uri, ContentResolver contentResolver) throws FileNotFoundException { - AssetFileDescriptor result = contentResolver.openAssetFileDescriptor(uri, "r"); + AssetFileDescriptor result = openAssetFileDescriptor(uri); if (result == null) { throw new FileNotFoundException("FileDescriptor is null for: " + uri); } diff --git a/library/src/main/java/com/bumptech/glide/load/data/FileDescriptorAssetPathFetcher.java b/library/src/main/java/com/bumptech/glide/load/data/FileDescriptorAssetPathFetcher.java index 50adeba32e..43c69300a1 100644 --- a/library/src/main/java/com/bumptech/glide/load/data/FileDescriptorAssetPathFetcher.java +++ b/library/src/main/java/com/bumptech/glide/load/data/FileDescriptorAssetPathFetcher.java @@ -1,30 +1,30 @@ package com.bumptech.glide.load.data; +import android.content.res.AssetFileDescriptor; import android.content.res.AssetManager; -import android.os.ParcelFileDescriptor; import androidx.annotation.NonNull; import java.io.IOException; -/** Fetches an {@link android.os.ParcelFileDescriptor} for an asset path. */ -public class FileDescriptorAssetPathFetcher extends AssetPathFetcher { +/** Fetches an {@link android.content.res.AssetFileDescriptor} for an asset path. */ +public class FileDescriptorAssetPathFetcher extends AssetPathFetcher { public FileDescriptorAssetPathFetcher(AssetManager assetManager, String assetPath) { super(assetManager, assetPath); } @Override - protected ParcelFileDescriptor loadResource(AssetManager assetManager, String path) + protected AssetFileDescriptor loadResource(AssetManager assetManager, String path) throws IOException { - return assetManager.openFd(path).getParcelFileDescriptor(); + return assetManager.openFd(path); } @Override - protected void close(ParcelFileDescriptor data) throws IOException { + protected void close(AssetFileDescriptor data) throws IOException { data.close(); } @NonNull @Override - public Class getDataClass() { - return ParcelFileDescriptor.class; + public Class getDataClass() { + return AssetFileDescriptor.class; } } diff --git a/library/src/main/java/com/bumptech/glide/load/data/FileDescriptorLocalUriFetcher.java b/library/src/main/java/com/bumptech/glide/load/data/FileDescriptorLocalUriFetcher.java index 1f484b3a74..e777032232 100644 --- a/library/src/main/java/com/bumptech/glide/load/data/FileDescriptorLocalUriFetcher.java +++ b/library/src/main/java/com/bumptech/glide/load/data/FileDescriptorLocalUriFetcher.java @@ -14,10 +14,19 @@ public FileDescriptorLocalUriFetcher(ContentResolver contentResolver, Uri uri) { super(contentResolver, uri); } + /** + * useMediaStoreApisIfAvailable is part of an experiment and the constructor can be removed in a + * future version. + */ + public FileDescriptorLocalUriFetcher( + ContentResolver contentResolver, Uri uri, boolean useMediaStoreApisIfAvailable) { + super(contentResolver, uri, useMediaStoreApisIfAvailable); + } + @Override protected ParcelFileDescriptor loadResource(Uri uri, ContentResolver contentResolver) throws FileNotFoundException { - AssetFileDescriptor assetFileDescriptor = contentResolver.openAssetFileDescriptor(uri, "r"); + AssetFileDescriptor assetFileDescriptor = openAssetFileDescriptor(uri); if (assetFileDescriptor == null) { throw new FileNotFoundException("FileDescriptor is null for: " + uri); } diff --git a/library/src/main/java/com/bumptech/glide/load/data/HttpUrlFetcher.java b/library/src/main/java/com/bumptech/glide/load/data/HttpUrlFetcher.java index 5602c1498c..5e8c6891af 100644 --- a/library/src/main/java/com/bumptech/glide/load/data/HttpUrlFetcher.java +++ b/library/src/main/java/com/bumptech/glide/load/data/HttpUrlFetcher.java @@ -28,6 +28,7 @@ public class HttpUrlFetcher implements DataFetcher { @VisibleForTesting static final HttpUrlConnectionFactory DEFAULT_CONNECTION_FACTORY = new DefaultHttpUrlConnectionFactory(); + /** Returned when a connection error prevented us from receiving an http error. */ @VisibleForTesting static final int INVALID_STATUS_CODE = -1; @@ -148,7 +149,7 @@ private HttpURLConnection buildAndConfigureConnection(URL url, Map headerEntry : headers.entrySet()) { urlConnection.addRequestProperty(headerEntry.getKey(), headerEntry.getValue()); diff --git a/library/src/main/java/com/bumptech/glide/load/data/LocalUriFetcher.java b/library/src/main/java/com/bumptech/glide/load/data/LocalUriFetcher.java index bd22c8016f..44f53a26e3 100644 --- a/library/src/main/java/com/bumptech/glide/load/data/LocalUriFetcher.java +++ b/library/src/main/java/com/bumptech/glide/load/data/LocalUriFetcher.java @@ -1,11 +1,13 @@ package com.bumptech.glide.load.data; import android.content.ContentResolver; +import android.content.res.AssetFileDescriptor; import android.net.Uri; import android.util.Log; import androidx.annotation.NonNull; import com.bumptech.glide.Priority; import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.data.mediastore.MediaStoreUtil; import java.io.FileNotFoundException; import java.io.IOException; @@ -17,6 +19,7 @@ * java.io.InputStream} or {@link android.os.ParcelFileDescriptor}. */ public abstract class LocalUriFetcher implements DataFetcher { + protected final boolean useMediaStoreApisIfAvailable; private static final String TAG = "LocalUriFetcher"; private final Uri uri; private final ContentResolver contentResolver; @@ -33,8 +36,23 @@ public abstract class LocalUriFetcher implements DataFetcher { // Public API. @SuppressWarnings("WeakerAccess") public LocalUriFetcher(ContentResolver contentResolver, Uri uri) { + this(contentResolver, uri, /* useMediaStoreApisIfAvailable */ false); + } + + /** + * Opens an input stream for a uri pointing to a local asset. Only certain uris are supported + * + * @param contentResolver Any {@link android.content.ContentResolver}. + * @param uri A Uri pointing to a local asset. This load will fail if the uri isn't openable by + * {@link ContentResolver#openInputStream(android.net.Uri)} + * @param useMediaStoreApisIfAvailable used to decide if the uri should be opened using MediaStore + * APIs + * @see ContentResolver#openInputStream(android.net.Uri) + */ + LocalUriFetcher(ContentResolver contentResolver, Uri uri, boolean useMediaStoreApisIfAvailable) { this.contentResolver = contentResolver; this.uri = uri; + this.useMediaStoreApisIfAvailable = useMediaStoreApisIfAvailable; } @Override @@ -73,6 +91,22 @@ public DataSource getDataSource() { return DataSource.LOCAL; } + /** + * Opens an {@link AssetFileDescriptor} for a uri pointing to a local asset. Depending on the + * {@code useMediaStoreApisIfAvailable} flag and the availability of MediaStore APIs, the uri may + * be opened using MediaStore APIs or {@link + * ContentResolver#openAssetFileDescriptor(android.net.Uri, String)}. + * + * @param uri A Uri pointing to a local asset. + */ + protected AssetFileDescriptor openAssetFileDescriptor(Uri uri) throws FileNotFoundException { + return useMediaStoreApisIfAvailable + && MediaStoreUtil.isMediaStoreUri(uri) + && MediaStoreUtil.isMediaStoreOpenFileApisAvailable() + ? MediaStoreUtil.openAssetFileDescriptor(uri, contentResolver) + : contentResolver.openAssetFileDescriptor(uri, "r"); + } + /** * Returns a concrete data type from the given {@link android.net.Uri} using the given {@link * android.content.ContentResolver}. diff --git a/library/src/main/java/com/bumptech/glide/load/data/ParcelFileDescriptorRewinder.java b/library/src/main/java/com/bumptech/glide/load/data/ParcelFileDescriptorRewinder.java index bd2be60a40..0f7c5f3c2c 100644 --- a/library/src/main/java/com/bumptech/glide/load/data/ParcelFileDescriptorRewinder.java +++ b/library/src/main/java/com/bumptech/glide/load/data/ParcelFileDescriptorRewinder.java @@ -18,8 +18,9 @@ public final class ParcelFileDescriptorRewinder implements DataRewinder= Build.VERSION_CODES.LOLLIPOP; + // Os.lseek() is only supported on API 21+ and does not work in Robolectric. + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && !"robolectric".equals(Build.FINGERPRINT); } @RequiresApi(Build.VERSION_CODES.LOLLIPOP) diff --git a/library/src/main/java/com/bumptech/glide/load/data/StreamLocalUriFetcher.java b/library/src/main/java/com/bumptech/glide/load/data/StreamLocalUriFetcher.java index 6477fb3bbc..79ac1205f6 100644 --- a/library/src/main/java/com/bumptech/glide/load/data/StreamLocalUriFetcher.java +++ b/library/src/main/java/com/bumptech/glide/load/data/StreamLocalUriFetcher.java @@ -2,9 +2,13 @@ import android.content.ContentResolver; import android.content.UriMatcher; +import android.content.res.AssetFileDescriptor; import android.net.Uri; +import android.os.Build.VERSION_CODES; import android.provider.ContactsContract; import androidx.annotation.NonNull; +import androidx.annotation.RequiresExtension; +import com.bumptech.glide.load.data.mediastore.MediaStoreUtil; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; @@ -13,20 +17,25 @@ public class StreamLocalUriFetcher extends LocalUriFetcher { /** A lookup uri (e.g. content://com.android.contacts/contacts/lookup/3570i61d948d30808e537) */ private static final int ID_CONTACTS_LOOKUP = 1; + /** A contact thumbnail uri (e.g. content://com.android.contacts/contacts/38/photo) */ private static final int ID_CONTACTS_THUMBNAIL = 2; + /** A contact uri (e.g. content://com.android.contacts/contacts/38) */ private static final int ID_CONTACTS_CONTACT = 3; + /** * A contact display photo (high resolution) uri (e.g. * content://com.android.contacts/5/display_photo) */ private static final int ID_CONTACTS_PHOTO = 4; + /** * Uri for optimized search of phones by number (e.g. * content://com.android.contacts/phone_lookup/232323232 */ private static final int ID_LOOKUP_BY_PHONE = 5; + /** Match the incoming Uri for special cases which we can handle nicely. */ private static final UriMatcher URI_MATCHER; @@ -44,6 +53,15 @@ public StreamLocalUriFetcher(ContentResolver resolver, Uri uri) { super(resolver, uri); } + /** + * useMediaStoreApisIfAvailable is part of an experiment and the constructor can be removed in a + * future version. + */ + public StreamLocalUriFetcher( + ContentResolver resolver, Uri uri, boolean useMediaStoreApisIfAvailable) { + super(resolver, uri, useMediaStoreApisIfAvailable); + } + @Override protected InputStream loadResource(Uri uri, ContentResolver contentResolver) throws FileNotFoundException { @@ -71,7 +89,13 @@ private InputStream loadResourceFromUri(Uri uri, ContentResolver contentResolver case ID_CONTACTS_PHOTO: case UriMatcher.NO_MATCH: default: - return contentResolver.openInputStream(uri); + if (useMediaStoreApisIfAvailable + && MediaStoreUtil.isMediaStoreUri(uri) + && MediaStoreUtil.isMediaStoreOpenFileApisAvailable()) { + return openMediaStoreFileInputStream(uri, contentResolver); + } else { + return contentResolver.openInputStream(uri); + } } } @@ -80,6 +104,29 @@ private InputStream openContactPhotoInputStream(ContentResolver contentResolver, contentResolver, contactUri, true /*preferHighres*/); } + @RequiresExtension( + extension = VERSION_CODES.R, + version = MediaStoreUtil.MIN_EXTENSION_VERSION_FOR_OPEN_FILE_APIS) + private InputStream openMediaStoreFileInputStream(Uri uri, ContentResolver contentResolver) + throws FileNotFoundException { + AssetFileDescriptor assetFileDescriptor = + MediaStoreUtil.openAssetFileDescriptor(uri, contentResolver); + if (assetFileDescriptor == null) { + throw new FileNotFoundException("FileDescriptor is null for: " + uri); + } + try { + return assetFileDescriptor.createInputStream(); + } catch (IOException exception) { + try { + assetFileDescriptor.close(); + } catch (Exception innerException) { + // Ignored + } + throw (FileNotFoundException) + new FileNotFoundException("Unable to create stream").initCause(exception); + } + } + @Override protected void close(InputStream data) throws IOException { data.close(); diff --git a/library/src/main/java/com/bumptech/glide/load/data/mediastore/MediaStoreUtil.java b/library/src/main/java/com/bumptech/glide/load/data/mediastore/MediaStoreUtil.java index bd00853e12..2e5970abb9 100644 --- a/library/src/main/java/com/bumptech/glide/load/data/mediastore/MediaStoreUtil.java +++ b/library/src/main/java/com/bumptech/glide/load/data/mediastore/MediaStoreUtil.java @@ -1,12 +1,20 @@ package com.bumptech.glide.load.data.mediastore; import android.content.ContentResolver; +import android.content.res.AssetFileDescriptor; import android.net.Uri; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.os.ext.SdkExtensions; import android.provider.MediaStore; +import androidx.annotation.ChecksSdkIntAtLeast; +import androidx.annotation.RequiresExtension; import com.bumptech.glide.request.target.Target; +import java.io.FileNotFoundException; /** Utility classes for interacting with the media store. */ public final class MediaStoreUtil { + public static final int MIN_EXTENSION_VERSION_FOR_OPEN_FILE_APIS = 17; private static final int MINI_THUMB_WIDTH = 512; private static final int MINI_THUMB_HEIGHT = 384; @@ -20,6 +28,41 @@ public static boolean isMediaStoreUri(Uri uri) { && MediaStore.AUTHORITY.equals(uri.getAuthority()); } + @ChecksSdkIntAtLeast(api = MIN_EXTENSION_VERSION_FOR_OPEN_FILE_APIS, extension = VERSION_CODES.R) + public static boolean isMediaStoreOpenFileApisAvailable() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + && SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) + >= MIN_EXTENSION_VERSION_FOR_OPEN_FILE_APIS; + } + + @RequiresExtension( + extension = VERSION_CODES.R, + version = MIN_EXTENSION_VERSION_FOR_OPEN_FILE_APIS) + public static AssetFileDescriptor openAssetFileDescriptor( + Uri uri, ContentResolver contentResolver) throws FileNotFoundException { + return MediaStore.openAssetFileDescriptor(contentResolver, uri, "r", null); + } + + /** + * Android picker URIs contain a "picker" prefix in one of their path segments. + * https://android.googlesource.com/platform/packages/providers/MediaProvider/+/refs/heads/master/src/com/android/providers/media/PickerUriResolver.java#58 + * + * @deprecated This method is retained solely to support existing use cases. It should not be + * utilized for new development or upcoming features. + */ + @Deprecated + public static boolean isAndroidPickerUri(Uri uri) { + if (!isMediaStoreUri(uri)) { + return false; + } + for (String segment : uri.getPathSegments()) { + if (segment != null && segment.startsWith("picker")) { + return true; + } + } + return false; + } + private static boolean isVideoUri(Uri uri) { return uri.getPathSegments().contains("video"); } diff --git a/library/src/main/java/com/bumptech/glide/load/engine/ActiveResources.java b/library/src/main/java/com/bumptech/glide/load/engine/ActiveResources.java index 6e883402c2..3a4c2f1867 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/ActiveResources.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/ActiveResources.java @@ -116,7 +116,11 @@ void cleanupActiveReference(@NonNull ResourceWeakReference ref) { EngineResource newResource = new EngineResource<>( - ref.resource, /*isMemoryCacheable=*/ true, /*isRecyclable=*/ false, ref.key, listener); + ref.resource, + /* isMemoryCacheable= */ true, + /* isRecyclable= */ false, + ref.key, + listener); listener.onResourceReleased(ref.key, newResource); } diff --git a/library/src/main/java/com/bumptech/glide/load/engine/DataCacheGenerator.java b/library/src/main/java/com/bumptech/glide/load/engine/DataCacheGenerator.java index bcec450888..409cc372b6 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/DataCacheGenerator.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/DataCacheGenerator.java @@ -6,6 +6,7 @@ import com.bumptech.glide.load.data.DataFetcher; import com.bumptech.glide.load.model.ModelLoader; import com.bumptech.glide.load.model.ModelLoader.LoadData; +import com.bumptech.glide.util.pool.GlideTrace; import java.io.File; import java.util.List; @@ -24,6 +25,7 @@ class DataCacheGenerator implements DataFetcherGenerator, DataFetcher.DataCallba private List> modelLoaders; private int modelLoaderIndex; private volatile LoadData loadData; + // PMD is wrong here, this File must be an instance variable because it may be used across // multiple calls to startNext. @SuppressWarnings("PMD.SingularField") @@ -43,38 +45,43 @@ class DataCacheGenerator implements DataFetcherGenerator, DataFetcher.DataCallba @Override public boolean startNext() { - while (modelLoaders == null || !hasNextModelLoader()) { - sourceIdIndex++; - if (sourceIdIndex >= cacheKeys.size()) { - return false; - } + GlideTrace.beginSection("DataCacheGenerator.startNext"); + try { + while (modelLoaders == null || !hasNextModelLoader()) { + sourceIdIndex++; + if (sourceIdIndex >= cacheKeys.size()) { + return false; + } - Key sourceId = cacheKeys.get(sourceIdIndex); - // PMD.AvoidInstantiatingObjectsInLoops The loop iterates a limited number of times - // and the actions it performs are much more expensive than a single allocation. - @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") - Key originalKey = new DataCacheKey(sourceId, helper.getSignature()); - cacheFile = helper.getDiskCache().get(originalKey); - if (cacheFile != null) { - this.sourceKey = sourceId; - modelLoaders = helper.getModelLoaders(cacheFile); - modelLoaderIndex = 0; + Key sourceId = cacheKeys.get(sourceIdIndex); + // PMD.AvoidInstantiatingObjectsInLoops The loop iterates a limited number of times + // and the actions it performs are much more expensive than a single allocation. + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + Key originalKey = new DataCacheKey(sourceId, helper.getSignature()); + cacheFile = helper.getDiskCache().get(originalKey); + if (cacheFile != null) { + this.sourceKey = sourceId; + modelLoaders = helper.getModelLoaders(cacheFile); + modelLoaderIndex = 0; + } } - } - loadData = null; - boolean started = false; - while (!started && hasNextModelLoader()) { - ModelLoader modelLoader = modelLoaders.get(modelLoaderIndex++); - loadData = - modelLoader.buildLoadData( - cacheFile, helper.getWidth(), helper.getHeight(), helper.getOptions()); - if (loadData != null && helper.hasLoadPath(loadData.fetcher.getDataClass())) { - started = true; - loadData.fetcher.loadData(helper.getPriority(), this); + loadData = null; + boolean started = false; + while (!started && hasNextModelLoader()) { + ModelLoader modelLoader = modelLoaders.get(modelLoaderIndex++); + loadData = + modelLoader.buildLoadData( + cacheFile, helper.getWidth(), helper.getHeight(), helper.getOptions()); + if (loadData != null && helper.hasLoadPath(loadData.fetcher.getDataClass())) { + started = true; + loadData.fetcher.loadData(helper.getPriority(), this); + } } + return started; + } finally { + GlideTrace.endSection(); } - return started; } private boolean hasNextModelLoader() { diff --git a/library/src/main/java/com/bumptech/glide/load/engine/DecodeHelper.java b/library/src/main/java/com/bumptech/glide/load/engine/DecodeHelper.java index b7dbebcc16..4324e74e5b 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/DecodeHelper.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/DecodeHelper.java @@ -8,6 +8,7 @@ import com.bumptech.glide.load.Options; import com.bumptech.glide.load.ResourceEncoder; import com.bumptech.glide.load.Transformation; +import com.bumptech.glide.load.data.DataRewinder; import com.bumptech.glide.load.engine.DecodeJob.DiskCacheProvider; import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; import com.bumptech.glide.load.engine.cache.DiskCache; @@ -99,6 +100,10 @@ DiskCacheStrategy getDiskCacheStrategy() { return diskCacheStrategy; } + DataRewinder getRewinder(T data) { + return glideContext.getRegistry().getRewinder(data); + } + Priority getPriority() { return priority; } diff --git a/library/src/main/java/com/bumptech/glide/load/engine/DecodeJob.java b/library/src/main/java/com/bumptech/glide/load/engine/DecodeJob.java index 324de7c118..7fffaceb74 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/DecodeJob.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/DecodeJob.java @@ -1,21 +1,29 @@ package com.bumptech.glide.load.engine; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; import android.os.Build; +import android.os.Process; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.util.Pools; +import com.bumptech.glide.GlideBuilder.OverrideGlideThreadPriority; import com.bumptech.glide.GlideContext; +import com.bumptech.glide.GlideExperiments; import com.bumptech.glide.Priority; import com.bumptech.glide.Registry; import com.bumptech.glide.load.DataSource; import com.bumptech.glide.load.EncodeStrategy; import com.bumptech.glide.load.Key; +import com.bumptech.glide.load.Option; import com.bumptech.glide.load.Options; import com.bumptech.glide.load.ResourceEncoder; import com.bumptech.glide.load.Transformation; import com.bumptech.glide.load.data.DataFetcher; import com.bumptech.glide.load.data.DataRewinder; import com.bumptech.glide.load.engine.cache.DiskCache; +import com.bumptech.glide.load.engine.executor.GlideExecutor; import com.bumptech.glide.load.resource.bitmap.Downsampler; import com.bumptech.glide.util.LogTime; import com.bumptech.glide.util.Synthetic; @@ -25,6 +33,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Supplier; /** * A class responsible for decoding resources either from cached data or from the original source @@ -42,6 +51,23 @@ class DecodeJob Poolable { private static final String TAG = "DecodeJob"; + /** + * {@link com.bumptech.glide.load.Option} to override the OS thread priority of the thread + * handling the decode job. + * + *

Acceptable values are integer constants defined in {@link android.os.Process}, ranging from + * {@link android.os.Process#THREAD_PRIORITY_LOWEST} to (-20). Any exceptions thrown will cause + * the override to fail silently and disable overrides on any subsequent jobs. + * + *

Must have {@link com.bumptech.glide.GlideBuilder#setOverrideGlideThreadPriority(boolean)} + * experiment enabled to be used. + * + *

This is used for a highly experimental API that may be removed in the future. Please use at + * your own risk. + */ + public static final Option> GLIDE_THREAD_PRIORITY_OVERRIDE = + Option.memory("glide_thread_priority_override"); + private final DecodeHelper decodeHelper = new DecodeHelper<>(); private final List throwables = new ArrayList<>(); private final StateVerifier stateVerifier = StateVerifier.newInstance(); @@ -65,6 +91,8 @@ class DecodeJob private long startFetchTime; private boolean onlyRetrieveFromCache; private Object model; + private GlideExperiments experiments; + @Nullable private Supplier glideThreadPriorityOverride; private Thread currentThread; private Key currentSourceKey; @@ -129,6 +157,8 @@ DecodeJob init( this.order = order; this.runReason = RunReason.INITIALIZE; this.model = model; + this.experiments = glideContext.getExperiments(); + this.glideThreadPriorityOverride = options.get(GLIDE_THREAD_PRIORITY_OVERRIDE); return this; } @@ -197,17 +227,13 @@ private void releaseInternal() { @Override public int compareTo(@NonNull DecodeJob other) { - int result = getPriority() - other.getPriority(); + int result = priority.compareTo(other.priority); if (result == 0) { result = order - other.order; } return result; } - private int getPriority() { - return priority.ordinal(); - } - public void cancel() { isCancelled = true; DataFetcherGenerator local = currentGenerator; @@ -223,7 +249,7 @@ public void run() { // This should be much more fine grained, but since Java's thread pool implementation silently // swallows all otherwise fatal exceptions, this will at least make it obvious to developers // that something is failing. - GlideTrace.beginSectionFormat("DecodeJob#run(model=%s)", model); + GlideTrace.beginSectionFormat("DecodeJob#run(reason=%s, model=%s)", runReason, model); // Methods in the try statement can invalidate currentFetcher, so set a local variable here to // ensure that the fetcher is cleaned up either way. DataFetcher localFetcher = currentFetcher; @@ -313,7 +339,7 @@ private void runGenerators() { currentGenerator = getNextGenerator(); if (stage == Stage.SOURCE) { - reschedule(); + reschedule(RunReason.SWITCH_TO_SOURCE_SERVICE); return; } } @@ -326,7 +352,33 @@ private void runGenerators() { // onDataFetcherReady. } + /** + * Restores the OS priority of the Glide thread to the default thread priority of {@link + * com.bumptech.glide.load.engine.executor.GlideExecutor}. + */ + private void restoreThreadPriority() { + if (!experiments.isEnabled(OverrideGlideThreadPriority.class)) { + throw new IllegalStateException("OverrideGlideThreadPriority experiment is not enabled."); + } + if (glideThreadPriorityOverride != null && glideThreadPriorityOverride.get() != null) { + try { + Process.setThreadPriority(Process.myTid(), GlideExecutor.DEFAULT_PRIORITY); + } catch (IllegalArgumentException | SecurityException e) { + glideThreadPriorityOverride = null; + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v( + TAG, + "Failed to set thread priority; using default priority for any subsequent jobs.", + e); + } + } + } + } + private void notifyFailed() { + if (experiments.isEnabled(OverrideGlideThreadPriority.class)) { + restoreThreadPriority(); + } setNotifiedOrThrow(); GlideException e = new GlideException("Failed to load resource", new ArrayList<>(throwables)); callback.onLoadFailed(e); @@ -335,6 +387,9 @@ private void notifyFailed() { private void notifyComplete( Resource resource, DataSource dataSource, boolean isLoadedFromAlternateCacheKey) { + if (experiments.isEnabled(OverrideGlideThreadPriority.class)) { + restoreThreadPriority(); + } setNotifiedOrThrow(); callback.onResourceReady(resource, dataSource, isLoadedFromAlternateCacheKey); } @@ -369,10 +424,16 @@ private Stage getNextStage(Stage current) { } } + private void reschedule(RunReason runReason) { + this.runReason = runReason; + callback.reschedule(this); + } + + // This is used by SourceGenerator to ask us to switch back to our thread. Internal methods in + // this class should call reschedule with a specific RunReason. @Override public void reschedule() { - runReason = RunReason.SWITCH_TO_SOURCE_SERVICE; - callback.reschedule(this); + reschedule(RunReason.SWITCH_TO_SOURCE_SERVICE); } @Override @@ -386,8 +447,7 @@ public void onDataFetcherReady( this.isLoadingFromAlternateCacheKey = sourceKey != decodeHelper.getCacheKeys().get(0); if (Thread.currentThread() != currentThread) { - runReason = RunReason.DECODE_DATA; - callback.reschedule(this); + reschedule(RunReason.DECODE_DATA); } else { GlideTrace.beginSection("DecodeJob.decodeFromRetrievedData"); try { @@ -406,8 +466,7 @@ public void onDataFetcherFailed( exception.setLoggingDetails(attemptedKey, dataSource, fetcher.getDataClass()); throwables.add(exception); if (Thread.currentThread() != currentThread) { - runReason = RunReason.SWITCH_TO_SOURCE_SERVICE; - callback.reschedule(this); + reschedule(RunReason.SWITCH_TO_SOURCE_SERVICE); } else { runGenerators(); } @@ -425,6 +484,21 @@ private void decodeFromRetrievedData() { + ", fetcher: " + currentFetcher); } + if (experiments.isEnabled(OverrideGlideThreadPriority.class) + && glideThreadPriorityOverride != null + && glideThreadPriorityOverride.get() != null) { + try { + Process.setThreadPriority(Process.myTid(), glideThreadPriorityOverride.get().intValue()); + } catch (IllegalArgumentException | SecurityException e) { + glideThreadPriorityOverride = null; + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v( + TAG, + "Failed to set thread priority; using default priority for any subsequent jobs.", + e); + } + } + } Resource resource = null; try { resource = decodeFromData(currentFetcher, currentData, currentDataSource); @@ -441,32 +515,38 @@ private void decodeFromRetrievedData() { private void notifyEncodeAndRelease( Resource resource, DataSource dataSource, boolean isLoadedFromAlternateCacheKey) { - if (resource instanceof Initializable) { - ((Initializable) resource).initialize(); - } + GlideTrace.beginSection("DecodeJob.notifyEncodeAndRelease"); + try { + if (resource instanceof Initializable) { + ((Initializable) resource).initialize(); + } - Resource result = resource; - LockedResource lockedResource = null; - if (deferredEncodeManager.hasResourceToEncode()) { - lockedResource = LockedResource.obtain(resource); - result = lockedResource; - } + Resource result = resource; + LockedResource lockedResource = null; + if (deferredEncodeManager.hasResourceToEncode()) { + lockedResource = LockedResource.obtain(resource); + result = lockedResource; + } - notifyComplete(result, dataSource, isLoadedFromAlternateCacheKey); + notifyComplete(result, dataSource, isLoadedFromAlternateCacheKey); - stage = Stage.ENCODE; - try { - if (deferredEncodeManager.hasResourceToEncode()) { - deferredEncodeManager.encode(diskCacheProvider, options); + stage = Stage.ENCODE; + try { + if (deferredEncodeManager.hasResourceToEncode()) { + deferredEncodeManager.encode(diskCacheProvider, options); + } + } finally { + if (lockedResource != null) { + lockedResource.unlock(); + } } + // Call onEncodeComplete outside the finally block so that it's not called if the encode + // process + // throws. + onEncodeComplete(); } finally { - if (lockedResource != null) { - lockedResource.unlock(); - } + GlideTrace.endSection(); } - // Call onEncodeComplete outside the finally block so that it's not called if the encode process - // throws. - onEncodeComplete(); } private Resource decodeFromData( @@ -565,7 +645,18 @@ Resource onResourceDecoded(DataSource dataSource, @NonNull Resource de Resource transformed = decoded; if (dataSource != DataSource.RESOURCE_DISK_CACHE) { appliedTransformation = decodeHelper.getTransformation(resourceSubClass); - transformed = appliedTransformation.transform(glideContext, decoded, width, height); + if (shouldBypassSoftwareTransformation(decoded, appliedTransformation)) { + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v( + TAG, + "Bypassing software transformations for resource: " + + decoded.get() + + " with transformation: " + + appliedTransformation); + } + } else { + transformed = appliedTransformation.transform(glideContext, decoded, width, height); + } } // TODO: Make this the responsibility of the Transformation. if (!decoded.equals(transformed)) { @@ -617,6 +708,41 @@ Resource onResourceDecoded(DataSource dataSource, @NonNull Resource de return result; } + /** + * Returns {@code true} if we should bypass applying software transformations to the decoded + * resource. + * + *

This is only true if the resource is a hardware bitmap, and both ALLOW_HARDWARE_CONFIG and + * BYPASS_TRANSFORMATIONS_FOR_HARDWARE_BITMAPS options are enabled. + */ + private boolean shouldBypassSoftwareTransformation( + Resource decoded, Transformation transformation) { + Boolean bypassOption = options.get(Downsampler.BYPASS_TRANSFORMATIONS_FOR_HARDWARE_BITMAPS); + if (bypassOption == null || !bypassOption) { + return false; + } + + Boolean allowHardware = options.get(Downsampler.ALLOW_HARDWARE_CONFIG); + if (allowHardware == null || !allowHardware) { + return false; + } + + Object resource = decoded.get(); + Bitmap bitmap = null; + if (resource instanceof Bitmap) { + bitmap = (Bitmap) resource; + } else if (resource instanceof BitmapDrawable) { + bitmap = ((BitmapDrawable) resource).getBitmap(); + } + + if (bitmap == null) { + return false; + } + + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + && bitmap.getConfig() == Bitmap.Config.HARDWARE; + } + private final class DecodeCallback implements DecodePath.DecodeCallback { private final DataSource dataSource; diff --git a/library/src/main/java/com/bumptech/glide/load/engine/DiskCacheStrategy.java b/library/src/main/java/com/bumptech/glide/load/engine/DiskCacheStrategy.java index b5b1379caf..3100826c91 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/DiskCacheStrategy.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/DiskCacheStrategy.java @@ -124,6 +124,7 @@ public boolean isDataCacheable(DataSource dataSource) { return dataSource == DataSource.REMOTE; } + @SuppressWarnings("checkstyle:UnnecessaryParentheses") // Readability @Override public boolean isResourceCacheable( boolean isFromAlternateCacheKey, DataSource dataSource, EncodeStrategy encodeStrategy) { diff --git a/library/src/main/java/com/bumptech/glide/load/engine/Engine.java b/library/src/main/java/com/bumptech/glide/load/engine/Engine.java index c469e967f5..cdedb29fbb 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/Engine.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/Engine.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.engine; +import android.graphics.Bitmap; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -21,16 +22,29 @@ import com.bumptech.glide.util.LogTime; import com.bumptech.glide.util.Preconditions; import com.bumptech.glide.util.Synthetic; +import com.bumptech.glide.util.Util; import com.bumptech.glide.util.pool.FactoryPools; import java.util.Map; import java.util.concurrent.Executor; /** Responsible for starting loads and managing active and cached resources. */ -public class Engine +public final class Engine implements EngineJobListener, MemoryCache.ResourceRemovedListener, EngineResource.ResourceListener { private static final String TAG = "Engine"; + + /** + * Log tag used for tracking memory allocations and cache hits for Bitmaps. + * + *

When logging is enabled at the {@link Log#DEBUG} level for this tag (e.g. via {@code adb + * shell setprop log.tag.GlideMemoryTracking DEBUG}), Glide will log detailed memory tracking + * information. This includes cache hits (from active resources or memory cache) and bitmap + * transformations or downsampling operations, along with bitmap dimensions, size in bytes, and + * identity hash codes. + */ + public static final String GLIDE_MEMORY_TRACKING_TAG = "GlideMemoryTracking"; + private static final int JOB_POOL_SIZE = 150; private static final boolean VERBOSE_IS_LOGGABLE = Log.isLoggable(TAG, Log.VERBOSE); private final Jobs jobs; @@ -57,12 +71,12 @@ public Engine( sourceExecutor, sourceUnlimitedExecutor, animationExecutor, - /*jobs=*/ null, - /*keyFactory=*/ null, - /*activeResources=*/ null, - /*engineJobFactory=*/ null, - /*decodeJobFactory=*/ null, - /*resourceRecycler=*/ null, + /* jobs= */ null, + /* keyFactory= */ null, + /* activeResources= */ null, + /* engineJobFactory= */ null, + /* decodeJobFactory= */ null, + /* resourceRecycler= */ null, isActiveResourceRetentionAllowed); } @@ -107,8 +121,8 @@ public Engine( sourceExecutor, sourceUnlimitedExecutor, animationExecutor, - /*engineJobListener=*/ this, - /*resourceListener=*/ this); + /* engineJobListener= */ this, + /* resourceListener= */ this); } this.engineJobFactory = engineJobFactory; @@ -304,6 +318,9 @@ private EngineResource loadFromMemory( if (VERBOSE_IS_LOGGABLE) { logWithTimeAndKey("Loaded resource from active resources", startTime, key); } + if (Log.isLoggable(GLIDE_MEMORY_TRACKING_TAG, Log.DEBUG)) { + logCacheHit("active", key, active); + } return active; } @@ -312,6 +329,9 @@ private EngineResource loadFromMemory( if (VERBOSE_IS_LOGGABLE) { logWithTimeAndKey("Loaded resource from cache", startTime, key); } + if (Log.isLoggable(GLIDE_MEMORY_TRACKING_TAG, Log.DEBUG)) { + logCacheHit("cache", key, cached); + } return cached; } @@ -322,6 +342,44 @@ private static void logWithTimeAndKey(String log, long startTime, Key key) { Log.v(TAG, log + " in " + LogTime.getElapsedMillis(startTime) + "ms, key: " + key); } + /** + * Logs memory cache hits (from active resources or memory cache). Handles both raw Bitmaps and + * Bitmaps wrapped inside BitmapDrawable resources. + */ + private static void logCacheHit(String source, EngineKey key, EngineResource resource) { + if (!Log.isLoggable(GLIDE_MEMORY_TRACKING_TAG, Log.DEBUG)) { + return; + } + + Object data = resource.get(); + Bitmap bitmap = null; + if (data instanceof Bitmap) { + bitmap = (Bitmap) data; + } else if (data instanceof android.graphics.drawable.BitmapDrawable) { + bitmap = ((android.graphics.drawable.BitmapDrawable) data).getBitmap(); + } + if (bitmap == null) { + return; + } + + int bitmapIdentity = System.identityHashCode(bitmap); + Log.d( + GLIDE_MEMORY_TRACKING_TAG, + "Engine [Device: " + + android.os.Build.DEVICE + + "]: Loaded bitmap [ID: " + + bitmapIdentity + + "] from memory cache (" + + source + + "). Size: [" + + bitmap.getWidth() + + "x" + + bitmap.getHeight() + + "] (" + + Util.getBitmapByteSize(bitmap) + + " bytes)"); + } + @Nullable private EngineResource loadFromActiveResources(Key key) { EngineResource active = activeResources.get(key); @@ -353,7 +411,11 @@ private EngineResource getEngineResourceFromCache(Key key) { } else { result = new EngineResource<>( - cached, /*isMemoryCacheable=*/ true, /*isRecyclable=*/ true, key, /*listener=*/ this); + cached, + /* isMemoryCacheable= */ true, + /* isRecyclable= */ true, + key, + /* listener= */ this); } return result; } @@ -387,7 +449,7 @@ public synchronized void onEngineJobCancelled(EngineJob engineJob, Key key) { public void onResourceRemoved(@NonNull final Resource resource) { // Avoid deadlock with RequestManagers when recycling triggers recursive clear() calls. // See b/145519760. - resourceRecycler.recycle(resource, /*forceNextFrame=*/ true); + resourceRecycler.recycle(resource, /* forceNextFrame= */ true); } @Override @@ -396,7 +458,7 @@ public void onResourceReleased(Key cacheKey, EngineResource resource) { if (resource.isMemoryCacheable()) { cache.put(cacheKey, resource); } else { - resourceRecycler.recycle(resource, /*forceNextFrame=*/ false); + resourceRecycler.recycle(resource, /* forceNextFrame= */ false); } } diff --git a/library/src/main/java/com/bumptech/glide/load/engine/EngineJob.java b/library/src/main/java/com/bumptech/glide/load/engine/EngineJob.java index 33151e0746..31fdef9364 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/EngineJob.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/EngineJob.java @@ -306,7 +306,7 @@ private synchronized void release() { isCancelled = false; hasResource = false; isLoadedFromAlternateCacheKey = false; - decodeJob.release(/*isRemovedFromQueue=*/ false); + decodeJob.release(/* isRemovedFromQueue= */ false); decodeJob = null; exception = null; dataSource = null; @@ -370,7 +370,7 @@ void notifyCallbacksOfException() { incrementPendingCallbacks(copy.size() + 1); } - engineJobListener.onEngineJobComplete(this, localKey, /*resource=*/ null); + engineJobListener.onEngineJobComplete(this, localKey, /* resource= */ null); for (ResourceCallbackAndExecutor entry : copy) { entry.executor.execute(new CallLoadFailed(entry.cb)); @@ -514,7 +514,7 @@ static class EngineResourceFactory { public EngineResource build( Resource resource, boolean isMemoryCacheable, Key key, ResourceListener listener) { return new EngineResource<>( - resource, isMemoryCacheable, /*isRecyclable=*/ true, key, listener); + resource, isMemoryCacheable, /* isRecyclable= */ true, key, listener); } } } diff --git a/library/src/main/java/com/bumptech/glide/load/engine/GlideException.java b/library/src/main/java/com/bumptech/glide/load/engine/GlideException.java index 2b35312cf4..32749ba6d0 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/GlideException.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/GlideException.java @@ -14,7 +14,9 @@ /** An exception with zero or more causes indicating why a load in Glide failed. */ // Public API. -@SuppressWarnings("WeakerAccess") +// Suppress serializable warnings because although Exception implements Serializable, GlideException +// is never serialized across processes or networks, making serialization checks irrelevant. +@SuppressWarnings({"WeakerAccess", "serial"}) public final class GlideException extends Exception { private static final long serialVersionUID = 1L; @@ -126,7 +128,7 @@ private void addRootCauses(Throwable throwable, List rootCauses) { for (Throwable t : glideException.getCauses()) { addRootCauses(t, rootCauses); } - } else { + } else if (throwable != null) { rootCauses.add(throwable); } } diff --git a/library/src/main/java/com/bumptech/glide/load/engine/ResourceCacheGenerator.java b/library/src/main/java/com/bumptech/glide/load/engine/ResourceCacheGenerator.java index c3bf6630bc..e8d563c34a 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/ResourceCacheGenerator.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/ResourceCacheGenerator.java @@ -7,6 +7,7 @@ import com.bumptech.glide.load.data.DataFetcher; import com.bumptech.glide.load.model.ModelLoader; import com.bumptech.glide.load.model.ModelLoader.LoadData; +import com.bumptech.glide.util.pool.GlideTrace; import java.io.File; import java.util.List; @@ -25,6 +26,7 @@ class ResourceCacheGenerator implements DataFetcherGenerator, DataFetcher.DataCa private List> modelLoaders; private int modelLoaderIndex; private volatile LoadData loadData; + // PMD is wrong here, this File must be an instance variable because it may be used across // multiple calls to startNext. @SuppressWarnings("PMD.SingularField") @@ -41,69 +43,74 @@ class ResourceCacheGenerator implements DataFetcherGenerator, DataFetcher.DataCa @SuppressWarnings("PMD.CollapsibleIfStatements") @Override public boolean startNext() { - List sourceIds = helper.getCacheKeys(); - if (sourceIds.isEmpty()) { - return false; - } - List> resourceClasses = helper.getRegisteredResourceClasses(); - if (resourceClasses.isEmpty()) { - if (File.class.equals(helper.getTranscodeClass())) { + GlideTrace.beginSection("ResourceCacheGenerator.startNext"); + try { + List sourceIds = helper.getCacheKeys(); + if (sourceIds.isEmpty()) { return false; } - throw new IllegalStateException( - "Failed to find any load path from " - + helper.getModelClass() - + " to " - + helper.getTranscodeClass()); - } - while (modelLoaders == null || !hasNextModelLoader()) { - resourceClassIndex++; - if (resourceClassIndex >= resourceClasses.size()) { - sourceIdIndex++; - if (sourceIdIndex >= sourceIds.size()) { + List> resourceClasses = helper.getRegisteredResourceClasses(); + if (resourceClasses.isEmpty()) { + if (File.class.equals(helper.getTranscodeClass())) { return false; } - resourceClassIndex = 0; + throw new IllegalStateException( + "Failed to find any load path from " + + helper.getModelClass() + + " to " + + helper.getTranscodeClass()); } + while (modelLoaders == null || !hasNextModelLoader()) { + resourceClassIndex++; + if (resourceClassIndex >= resourceClasses.size()) { + sourceIdIndex++; + if (sourceIdIndex >= sourceIds.size()) { + return false; + } + resourceClassIndex = 0; + } - Key sourceId = sourceIds.get(sourceIdIndex); - Class resourceClass = resourceClasses.get(resourceClassIndex); - Transformation transformation = helper.getTransformation(resourceClass); - // PMD.AvoidInstantiatingObjectsInLoops Each iteration is comparatively expensive anyway, - // we only run until the first one succeeds, the loop runs for only a limited - // number of iterations on the order of 10-20 in the worst case. - currentKey = - new ResourceCacheKey( // NOPMD AvoidInstantiatingObjectsInLoops - helper.getArrayPool(), - sourceId, - helper.getSignature(), - helper.getWidth(), - helper.getHeight(), - transformation, - resourceClass, - helper.getOptions()); - cacheFile = helper.getDiskCache().get(currentKey); - if (cacheFile != null) { - sourceKey = sourceId; - modelLoaders = helper.getModelLoaders(cacheFile); - modelLoaderIndex = 0; + Key sourceId = sourceIds.get(sourceIdIndex); + Class resourceClass = resourceClasses.get(resourceClassIndex); + Transformation transformation = helper.getTransformation(resourceClass); + // PMD.AvoidInstantiatingObjectsInLoops Each iteration is comparatively expensive anyway, + // we only run until the first one succeeds, the loop runs for only a limited + // number of iterations on the order of 10-20 in the worst case. + currentKey = + new ResourceCacheKey( // NOPMD AvoidInstantiatingObjectsInLoops + helper.getArrayPool(), + sourceId, + helper.getSignature(), + helper.getWidth(), + helper.getHeight(), + transformation, + resourceClass, + helper.getOptions()); + cacheFile = helper.getDiskCache().get(currentKey); + if (cacheFile != null) { + sourceKey = sourceId; + modelLoaders = helper.getModelLoaders(cacheFile); + modelLoaderIndex = 0; + } } - } - loadData = null; - boolean started = false; - while (!started && hasNextModelLoader()) { - ModelLoader modelLoader = modelLoaders.get(modelLoaderIndex++); - loadData = - modelLoader.buildLoadData( - cacheFile, helper.getWidth(), helper.getHeight(), helper.getOptions()); - if (loadData != null && helper.hasLoadPath(loadData.fetcher.getDataClass())) { - started = true; - loadData.fetcher.loadData(helper.getPriority(), this); + loadData = null; + boolean started = false; + while (!started && hasNextModelLoader()) { + ModelLoader modelLoader = modelLoaders.get(modelLoaderIndex++); + loadData = + modelLoader.buildLoadData( + cacheFile, helper.getWidth(), helper.getHeight(), helper.getOptions()); + if (loadData != null && helper.hasLoadPath(loadData.fetcher.getDataClass())) { + started = true; + loadData.fetcher.loadData(helper.getPriority(), this); + } } - } - return started; + return started; + } finally { + GlideTrace.endSection(); + } } private boolean hasNextModelLoader() { diff --git a/library/src/main/java/com/bumptech/glide/load/engine/SourceGenerator.java b/library/src/main/java/com/bumptech/glide/load/engine/SourceGenerator.java index 59ec17004c..9a3c63ce2d 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/SourceGenerator.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/SourceGenerator.java @@ -8,10 +8,13 @@ import com.bumptech.glide.load.Key; import com.bumptech.glide.load.data.DataFetcher; import com.bumptech.glide.load.data.DataFetcher.DataCallback; +import com.bumptech.glide.load.data.DataRewinder; +import com.bumptech.glide.load.engine.cache.DiskCache; import com.bumptech.glide.load.model.ModelLoader; import com.bumptech.glide.load.model.ModelLoader.LoadData; import com.bumptech.glide.util.LogTime; import com.bumptech.glide.util.Synthetic; +import java.io.IOException; import java.util.Collections; /** @@ -21,6 +24,9 @@ * *

Depending on the disk cache strategy, source data may first be written to disk and then loaded * from the cache file rather than returned directly. + * + *

This object may be used by multiple threads, but only one at a time. It is not safe to access + * this object on multiple threads concurrently. */ class SourceGenerator implements DataFetcherGenerator, DataFetcherGenerator.FetcherReadyCallback { private static final String TAG = "SourceGenerator"; @@ -28,23 +34,42 @@ class SourceGenerator implements DataFetcherGenerator, DataFetcherGenerator.Fetc private final DecodeHelper helper; private final FetcherReadyCallback cb; - private int loadDataListIndex; - private DataCacheGenerator sourceCacheGenerator; - private Object dataToCache; + private volatile int loadDataListIndex; + private volatile DataCacheGenerator sourceCacheGenerator; + private volatile Object dataToCache; private volatile ModelLoader.LoadData loadData; - private DataCacheKey originalKey; + private volatile DataCacheKey originalKey; SourceGenerator(DecodeHelper helper, FetcherReadyCallback cb) { this.helper = helper; this.cb = cb; } + // Concurrent access isn't supported. + @SuppressWarnings({"NonAtomicOperationOnVolatileField", "NonAtomicVolatileUpdate"}) @Override public boolean startNext() { if (dataToCache != null) { Object data = dataToCache; dataToCache = null; - cacheData(data); + try { + boolean isDataInCache = cacheData(data); + // If we failed to write the data to cache, the cacheData method will try to decode the + // original data directly instead of going through the disk cache. Since cacheData has + // already called our callback at this point, there's nothing more to do but return. + if (!isDataInCache) { + return true; + } + // If we were able to write the data to cache successfully, we now need to proceed to call + // the sourceCacheGenerator below to load the data from cache. + } catch (IOException e) { + // An IOException means we weren't able to write data to cache or we weren't able to rewind + // it after a disk cache write failed. In either case we can just move on and try the next + // fetch below. + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Failed to properly rewind or write data to cache", e); + } + } } if (sourceCacheGenerator != null && sourceCacheGenerator.startNext()) { @@ -98,20 +123,28 @@ private boolean hasNextModelLoader() { return loadDataListIndex < helper.getLoadData().size(); } - private void cacheData(Object dataToCache) { + /** + * Returns {@code true} if we were able to cache the data and should try to decode the data + * directly from cache and {@code false} if we were unable to cache the data and should make an + * attempt to decode from source. + */ + private boolean cacheData(Object dataToCache) throws IOException { long startTime = LogTime.getLogTime(); + boolean isLoadingFromSourceData = false; try { - Encoder encoder = helper.getSourceEncoder(dataToCache); - DataCacheWriter writer = - new DataCacheWriter<>(encoder, dataToCache, helper.getOptions()); - originalKey = new DataCacheKey(loadData.sourceKey, helper.getSignature()); - helper.getDiskCache().put(originalKey, writer); + DataRewinder rewinder = helper.getRewinder(dataToCache); + Object data = rewinder.rewindAndGet(); + Encoder encoder = helper.getSourceEncoder(data); + DataCacheWriter writer = new DataCacheWriter<>(encoder, data, helper.getOptions()); + DataCacheKey newOriginalKey = new DataCacheKey(loadData.sourceKey, helper.getSignature()); + DiskCache diskCache = helper.getDiskCache(); + diskCache.put(newOriginalKey, writer); if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v( TAG, "Finished encoding source to cache" + ", key: " - + originalKey + + newOriginalKey + ", data: " + dataToCache + ", encoder: " @@ -119,12 +152,41 @@ private void cacheData(Object dataToCache) { + ", duration: " + LogTime.getElapsedMillis(startTime)); } + + if (diskCache.get(newOriginalKey) != null) { + originalKey = newOriginalKey; + sourceCacheGenerator = + new DataCacheGenerator(Collections.singletonList(loadData.sourceKey), helper, this); + // We were able to write the data to cache. + return true; + } else { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d( + TAG, + "Attempt to write: " + + originalKey + + ", data: " + + dataToCache + + " to the disk" + + " cache failed, maybe the disk cache is disabled?" + + " Trying to decode the data directly..."); + } + + isLoadingFromSourceData = true; + cb.onDataFetcherReady( + loadData.sourceKey, + rewinder.rewindAndGet(), + loadData.fetcher, + loadData.fetcher.getDataSource(), + loadData.sourceKey); + } + // We failed to write the data to cache. + return false; } finally { - loadData.fetcher.cleanup(); + if (!isLoadingFromSourceData) { + loadData.fetcher.cleanup(); + } } - - sourceCacheGenerator = - new DataCacheGenerator(Collections.singletonList(loadData.sourceKey), helper, this); } @Override @@ -142,7 +204,8 @@ void onDataReadyInternal(LoadData loadData, Object data) { if (data != null && diskCacheStrategy.isDataCacheable(loadData.fetcher.getDataSource())) { dataToCache = data; // We might be being called back on someone else's thread. Before doing anything, we should - // reschedule to get back onto Glide's thread. + // reschedule to get back onto Glide's thread. Then once we're back on Glide's thread, we'll + // get called again and we can write the retrieved data to cache. cb.reschedule(); } else { cb.onDataFetcherReady( diff --git a/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/ArrayAdapterInterface.java b/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/ArrayAdapterInterface.java index 655a980f60..2913e501de 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/ArrayAdapterInterface.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/ArrayAdapterInterface.java @@ -1,4 +1,5 @@ package com.bumptech.glide.load.engine.bitmap_recycle; + /** * Interface for handling operations on a primitive array type. * diff --git a/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/ArrayPool.java b/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/ArrayPool.java index b9a6b8dc1c..19f0a4824f 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/ArrayPool.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/ArrayPool.java @@ -26,7 +26,7 @@ public interface ArrayPool { void put(T array); /** - * Returns a non-null array of the given type with a length >= to the given size. + * Returns a non-null array of the given type with a length {@code >=} to the given size. * *

If an array of the given size isn't in the pool, a new one will be allocated. * diff --git a/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/BitmapPoolAdapter.java b/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/BitmapPoolAdapter.java index b0dfc41edc..cedbba2728 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/BitmapPoolAdapter.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/BitmapPoolAdapter.java @@ -5,8 +5,8 @@ /** * An {@link com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool BitmapPool} implementation - * that rejects all {@link android.graphics.Bitmap Bitmap}s added to it and always returns {@code - * null} from get. + * that rejects all {@link android.graphics.Bitmap Bitmap}s added to it and always returns a new + * {@link android.graphics.Bitmap Bitmap} from {@link #get}. */ public class BitmapPoolAdapter implements BitmapPool { @Override diff --git a/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/LruArrayPool.java b/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/LruArrayPool.java index 874f3b02b8..d1db9776d1 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/LruArrayPool.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/LruArrayPool.java @@ -23,6 +23,7 @@ public final class LruArrayPool implements ArrayPool { * to be returned from the pool. */ @VisibleForTesting static final int MAX_OVER_SIZE_MULTIPLE = 8; + /** Used to calculate the maximum % of the total pool size a single byte array may consume. */ private static final int SINGLE_ARRAY_MAX_SIZE_DIVISOR = 2; @@ -126,7 +127,7 @@ private boolean mayFillRequest(int requestedSize, Integer actualSize) { } private boolean isNoMoreThanHalfFull() { - return currentSize == 0 || (maxSize / currentSize >= 2); + return currentSize == 0 || maxSize / currentSize >= 2; } @Override diff --git a/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/LruBitmapPool.java b/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/LruBitmapPool.java index 8ebf35628d..17f96f518c 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/LruBitmapPool.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/bitmap_recycle/LruBitmapPool.java @@ -239,18 +239,19 @@ public void clearMemory() { trimToSize(0); } + @SuppressWarnings("checkstyle:UnnecessaryParentheses") // Readability @SuppressLint("InlinedApi") @Override public void trimMemory(int level) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "trimMemory, level=" + level); } - if ((level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) - || ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) - && (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN))) { + if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND + || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN)) { clearMemory(); - } else if ((level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) - || (level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL)) { + } else if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN + || level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { trimToSize(getMaxSize() / 2); } } diff --git a/library/src/main/java/com/bumptech/glide/load/engine/cache/DiskLruCacheWrapper.java b/library/src/main/java/com/bumptech/glide/load/engine/cache/DiskLruCacheWrapper.java index 516bee1f0c..57a540b28e 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/cache/DiskLruCacheWrapper.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/cache/DiskLruCacheWrapper.java @@ -27,6 +27,7 @@ public class DiskLruCacheWrapper implements DiskCache { private final SafeKeyGenerator safeKeyGenerator; private final File directory; private final long maxSize; + private final boolean memoizePathNames; private final DiskCacheWriteLocker writeLocker = new DiskCacheWriteLocker(); private DiskLruCache diskLruCache; @@ -60,22 +61,48 @@ public static synchronized DiskCache get(File directory, long maxSize) { */ @SuppressWarnings("deprecation") public static DiskCache create(File directory, long maxSize) { - return new DiskLruCacheWrapper(directory, maxSize); + return new DiskLruCacheWrapper(directory, maxSize, /* memoizePathNames= */ false); } - /** @deprecated Do not extend this class. */ + /** + * Create a new DiskCache in the given directory with a specified max size and memoization + * behavior. + * + * @param directory The directory for the disk cache + * @param maxSize The max size for the disk cache + * @param memoizePathNames Whether to memoize path names + * @return The new disk cache with the given arguments + * @deprecated Enabling the memoization is a deprecated setting that will be removed in a future + * version. + */ + @SuppressWarnings("deprecation") + @Deprecated + public static DiskCache create(File directory, long maxSize, boolean memoizePathNames) { + return new DiskLruCacheWrapper(directory, maxSize, memoizePathNames); + } + + /** + * @deprecated Do not extend this class. + */ @Deprecated // Deprecated public API. @SuppressWarnings({"WeakerAccess", "DeprecatedIsStillUsed"}) protected DiskLruCacheWrapper(File directory, long maxSize) { + this(directory, maxSize, /* memoizePathNames= */ false); + } + + protected DiskLruCacheWrapper(File directory, long maxSize, boolean memoizePathNames) { this.directory = directory; this.maxSize = maxSize; + this.memoizePathNames = memoizePathNames; this.safeKeyGenerator = new SafeKeyGenerator(); } private synchronized DiskLruCache getDiskCache() throws IOException { if (diskLruCache == null) { - diskLruCache = DiskLruCache.open(directory, APP_VERSION, VALUE_COUNT, maxSize); + diskLruCache = + DiskLruCache.experimentalOpen( + directory, APP_VERSION, VALUE_COUNT, maxSize, memoizePathNames); } return diskLruCache; } diff --git a/library/src/main/java/com/bumptech/glide/load/engine/cache/ExternalPreferredCacheDiskCacheFactory.java b/library/src/main/java/com/bumptech/glide/load/engine/cache/ExternalPreferredCacheDiskCacheFactory.java index 1ac0a82200..db3f8ff177 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/cache/ExternalPreferredCacheDiskCacheFactory.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/cache/ExternalPreferredCacheDiskCacheFactory.java @@ -48,14 +48,14 @@ public File getCacheDirectory() { // Already used internal cache, so keep using that one, // thus avoiding using both external and internal with transient errors. - if ((null != internalCacheDirectory) && internalCacheDirectory.exists()) { + if (internalCacheDirectory != null && internalCacheDirectory.exists()) { return internalCacheDirectory; } File cacheDirectory = context.getExternalCacheDir(); // Shared storage is not available. - if ((cacheDirectory == null) || (!cacheDirectory.canWrite())) { + if (cacheDirectory == null || !cacheDirectory.canWrite()) { return internalCacheDirectory; } if (diskCacheName != null) { diff --git a/library/src/main/java/com/bumptech/glide/load/engine/cache/MemoryCache.java b/library/src/main/java/com/bumptech/glide/load/engine/cache/MemoryCache.java index f48f4c4f29..1fa69362fd 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/cache/MemoryCache.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/cache/MemoryCache.java @@ -25,7 +25,7 @@ interface ResourceRemovedListener { *

If the size multiplier causes the size of the cache to be decreased, items will be evicted * until the cache is smaller than the new size. * - * @param multiplier A size multiplier >= 0. + * @param multiplier A size multiplier {@code >= 0}. */ void setSizeMultiplier(float multiplier); diff --git a/library/src/main/java/com/bumptech/glide/load/engine/executor/GlideExecutor.java b/library/src/main/java/com/bumptech/glide/load/engine/executor/GlideExecutor.java index 6237d2cd71..6e85a4db31 100644 --- a/library/src/main/java/com/bumptech/glide/load/engine/executor/GlideExecutor.java +++ b/library/src/main/java/com/bumptech/glide/load/engine/executor/GlideExecutor.java @@ -20,6 +20,8 @@ import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; /** A prioritized {@link ThreadPoolExecutor} for running jobs in Glide. */ public final class GlideExecutor implements ExecutorService { @@ -27,19 +29,19 @@ public final class GlideExecutor implements ExecutorService { * The default thread name prefix for executors used to load/decode/transform data not found in * cache. */ - private static final String DEFAULT_SOURCE_EXECUTOR_NAME = "source"; + static final String DEFAULT_SOURCE_EXECUTOR_NAME = "source"; /** * The default thread name prefix for executors used to load/decode/transform data found in * Glide's cache. */ - private static final String DEFAULT_DISK_CACHE_EXECUTOR_NAME = "disk-cache"; + static final String DEFAULT_DISK_CACHE_EXECUTOR_NAME = "disk-cache"; /** * The default thread count for executors used to load/decode/transform data found in Glide's * cache. */ - private static final int DEFAULT_DISK_CACHE_EXECUTOR_THREADS = 1; + static final int DEFAULT_DISK_CACHE_EXECUTOR_THREADS = 1; private static final String TAG = "GlideExecutor"; @@ -49,7 +51,7 @@ public final class GlideExecutor implements ExecutorService { */ private static final String DEFAULT_SOURCE_UNLIMITED_EXECUTOR_NAME = "source-unlimited"; - private static final String DEFAULT_ANIMATION_EXECUTOR_NAME = "animation"; + static final String DEFAULT_ANIMATION_EXECUTOR_NAME = "animation"; /** The default keep alive time for threads in our cached thread pools in milliseconds. */ private static final long KEEP_ALIVE_TIME_MS = TimeUnit.SECONDS.toMillis(10); @@ -63,6 +65,11 @@ public final class GlideExecutor implements ExecutorService { private final ExecutorService delegate; + /** The default priority for threads created by Glide. */ + public static final int DEFAULT_PRIORITY = + android.os.Process.THREAD_PRIORITY_BACKGROUND + + android.os.Process.THREAD_PRIORITY_MORE_FAVORABLE; + /** * Returns a new {@link Builder} with the {@link #DEFAULT_DISK_CACHE_EXECUTOR_THREADS} threads, * {@link #DEFAULT_DISK_CACHE_EXECUTOR_NAME} name and {@link UncaughtThrowableStrategy#DEFAULT} @@ -71,7 +78,7 @@ public final class GlideExecutor implements ExecutorService { *

Disk cache executors do not allow network operations on their threads. */ public static GlideExecutor.Builder newDiskCacheBuilder() { - return new GlideExecutor.Builder(/*preventNetworkOperations=*/ true) + return new GlideExecutor.Builder(/* preventNetworkOperations= */ true) .setThreadCount(DEFAULT_DISK_CACHE_EXECUTOR_THREADS) .setName(DEFAULT_DISK_CACHE_EXECUTOR_NAME); } @@ -93,7 +100,9 @@ public static GlideExecutor newDiskCacheExecutor( return newDiskCacheBuilder().setUncaughtThrowableStrategy(uncaughtThrowableStrategy).build(); } - /** @deprecated Use {@link #newDiskCacheBuilder()} instead. */ + /** + * @deprecated Use {@link #newDiskCacheBuilder()} instead. + */ // Public API. @SuppressWarnings("WeakerAccess") @Deprecated @@ -116,7 +125,7 @@ public static GlideExecutor newDiskCacheExecutor( *

Source executors allow network operations on their threads. */ public static GlideExecutor.Builder newSourceBuilder() { - return new GlideExecutor.Builder(/*preventNetworkOperations=*/ false) + return new GlideExecutor.Builder(/* preventNetworkOperations= */ false) .setThreadCount(calculateBestThreadCount()) .setName(DEFAULT_SOURCE_EXECUTOR_NAME); } @@ -126,7 +135,9 @@ public static GlideExecutor newSourceExecutor() { return newSourceBuilder().build(); } - /** @deprecated Use {@link #newSourceBuilder()} instead. */ + /** + * @deprecated Use {@link #newSourceBuilder()} instead. + */ // Public API. @SuppressWarnings("unused") @Deprecated @@ -135,7 +146,9 @@ public static GlideExecutor newSourceExecutor( return newSourceBuilder().setUncaughtThrowableStrategy(uncaughtThrowableStrategy).build(); } - /** @deprecated Use {@link #newSourceBuilder()} instead. */ + /** + * @deprecated Use {@link #newSourceBuilder()} instead. + */ // Public API. @SuppressWarnings("WeakerAccess") @Deprecated @@ -151,7 +164,7 @@ public static GlideExecutor newSourceExecutor( /** * Returns a new unlimited thread pool with zero core thread count to make sure no threads are * created by default, {@link #KEEP_ALIVE_TIME_MS} keep alive time, the {@link - * #SOURCE_UNLIMITED_EXECUTOR_NAME} thread name prefix, the {@link + * #DEFAULT_SOURCE_UNLIMITED_EXECUTOR_NAME} thread name prefix, the {@link * com.bumptech.glide.load.engine.executor.GlideExecutor.UncaughtThrowableStrategy#DEFAULT} * uncaught throwable strategy, and the {@link SynchronousQueue} since using default unbounded * blocking queue, for example, {@link PriorityBlockingQueue} effectively won't create more than @@ -170,7 +183,10 @@ public static GlideExecutor newUnlimitedSourceExecutor() { TimeUnit.MILLISECONDS, new SynchronousQueue(), new DefaultThreadFactory( - DEFAULT_SOURCE_UNLIMITED_EXECUTOR_NAME, UncaughtThrowableStrategy.DEFAULT, false))); + new DefaultPriorityThreadFactory(), + DEFAULT_SOURCE_UNLIMITED_EXECUTOR_NAME, + UncaughtThrowableStrategy.DEFAULT, + false))); } /** @@ -180,17 +196,20 @@ public static GlideExecutor newUnlimitedSourceExecutor() { *

Animation executors do not allow network operations on their threads. */ public static GlideExecutor.Builder newAnimationBuilder() { + int maximumPoolSize = calculateAnimationExecutorThreadCount(); + return new GlideExecutor.Builder(/* preventNetworkOperations= */ true) + .setThreadCount(maximumPoolSize) + .setName(DEFAULT_ANIMATION_EXECUTOR_NAME); + } + + static int calculateAnimationExecutorThreadCount() { int bestThreadCount = calculateBestThreadCount(); // We don't want to add a ton of threads running animations in parallel with our source and // disk cache executors. Doing so adds unnecessary CPU load and can also dramatically increase // our maximum memory usage. Typically one thread is sufficient here, but for higher end devices // with more cores, two threads can provide better performance if lots of GIFs are showing at // once. - int maximumPoolSize = bestThreadCount >= 4 ? 2 : 1; - - return new GlideExecutor.Builder(/*preventNetworkOperations=*/ true) - .setThreadCount(maximumPoolSize) - .setName(DEFAULT_ANIMATION_EXECUTOR_NAME); + return bestThreadCount >= 4 ? 2 : 1; } /** Shortcut for calling {@link Builder#build()} on {@link #newAnimationBuilder()}. */ @@ -198,7 +217,9 @@ public static GlideExecutor newAnimationExecutor() { return newAnimationBuilder().build(); } - /** @deprecated Use {@link #newAnimationBuilder()} instead. */ + /** + * @deprecated Use {@link #newAnimationBuilder()} instead. + */ // Public API. @SuppressWarnings("WeakerAccess") @Deprecated @@ -324,6 +345,7 @@ public void handle(Throwable t) { // ignore } }; + /** Logs the uncaught {@link Throwable}s using {@link #TAG} and {@link Log}. */ UncaughtThrowableStrategy LOG = new UncaughtThrowableStrategy() { @@ -334,6 +356,7 @@ public void handle(Throwable t) { } } }; + /** Rethrows the uncaught {@link Throwable}s to crash the app. */ // Public API. @SuppressWarnings("unused") @@ -353,51 +376,64 @@ public void handle(Throwable t) { void handle(Throwable t); } + private static final class DefaultPriorityThreadFactory implements ThreadFactory { + + @Override + public Thread newThread(@NonNull Runnable runnable) { + return new Thread(runnable) { + @Override + public void run() { + // why PMD suppression is needed: https://github.com/pmd/pmd/issues/808 + android.os.Process.setThreadPriority(DEFAULT_PRIORITY); // NOPMD AccessorMethodGeneration + super.run(); + } + }; + } + } + /** * A {@link java.util.concurrent.ThreadFactory} that builds threads slightly above priority {@link * android.os.Process#THREAD_PRIORITY_BACKGROUND}. */ private static final class DefaultThreadFactory implements ThreadFactory { - private static final int DEFAULT_PRIORITY = - android.os.Process.THREAD_PRIORITY_BACKGROUND - + android.os.Process.THREAD_PRIORITY_MORE_FAVORABLE; + private final ThreadFactory delegate; private final String name; @Synthetic final UncaughtThrowableStrategy uncaughtThrowableStrategy; @Synthetic final boolean preventNetworkOperations; - private int threadNum; + private final AtomicInteger threadNum = new AtomicInteger(); DefaultThreadFactory( + ThreadFactory delegate, String name, UncaughtThrowableStrategy uncaughtThrowableStrategy, boolean preventNetworkOperations) { + this.delegate = delegate; this.name = name; this.uncaughtThrowableStrategy = uncaughtThrowableStrategy; this.preventNetworkOperations = preventNetworkOperations; } @Override - public synchronized Thread newThread(@NonNull Runnable runnable) { - final Thread result = - new Thread(runnable, "glide-" + name + "-thread-" + threadNum) { - @Override - public void run() { - // why PMD suppression is needed: https://github.com/pmd/pmd/issues/808 - android.os.Process.setThreadPriority( - DEFAULT_PRIORITY); // NOPMD AccessorMethodGeneration - if (preventNetworkOperations) { - StrictMode.setThreadPolicy( - new ThreadPolicy.Builder().detectNetwork().penaltyDeath().build()); - } - try { - super.run(); - } catch (Throwable t) { - uncaughtThrowableStrategy.handle(t); - } - } - }; - threadNum++; - return result; + public Thread newThread(@NonNull final Runnable runnable) { + Thread newThread = + delegate.newThread( + new Runnable() { + @Override + public void run() { + if (preventNetworkOperations) { + StrictMode.setThreadPolicy( + new ThreadPolicy.Builder().detectNetwork().penaltyDeath().build()); + } + try { + runnable.run(); + } catch (Throwable t) { + uncaughtThrowableStrategy.handle(t); + } + } + }); + newThread.setName("glide-" + name + "-thread-" + threadNum.getAndIncrement()); + return newThread; } } @@ -414,11 +450,14 @@ public static final class Builder { private int corePoolSize; private int maximumPoolSize; + @NonNull private ThreadFactory threadFactory = new DefaultPriorityThreadFactory(); + @NonNull private UncaughtThrowableStrategy uncaughtThrowableStrategy = UncaughtThrowableStrategy.DEFAULT; private String name; private long threadTimeoutMillis; + @Synthetic Function onExecuteDecorator; @Synthetic Builder(boolean preventNetworkOperations) { @@ -443,6 +482,22 @@ public Builder setThreadCount(@IntRange(from = 1) int threadCount) { return this; } + /** + * Sets the {@link ThreadFactory} responsible for creating threads and setting their priority. + * + *

Usage of this method may override other options on this builder. No guarantees are + * provided with regards to the behavior of this method or how it interacts with other methods + * on the builder. Use at your own risk. + * + * @deprecated This is an experimental method that may be removed without warning in a future + * version. + */ + @Deprecated + public Builder setThreadFactory(@NonNull ThreadFactory threadFactory) { + this.threadFactory = threadFactory; + return this; + } + /** * Sets the {@link UncaughtThrowableStrategy} to use for unexpected exceptions thrown by tasks * on {@link GlideExecutor}s built by this {@code Builder}. @@ -461,20 +516,51 @@ public Builder setName(String name) { return this; } + /** + * Sets the decorator to be applied to each runnable executed by the executor. + * + *

This is an experimental method that may be removed without warning in a future version. + */ + public Builder experimentalSetOnExecuteDecorator( + Function onExecuteDecorator) { + this.onExecuteDecorator = onExecuteDecorator; + return this; + } + /** Builds a new {@link GlideExecutor} with any previously specified options. */ public GlideExecutor build() { if (TextUtils.isEmpty(name)) { throw new IllegalArgumentException( "Name must be non-null and non-empty, but given: " + name); } - ThreadPoolExecutor executor = - new ThreadPoolExecutor( - corePoolSize, - maximumPoolSize, - /*keepAliveTime=*/ threadTimeoutMillis, - TimeUnit.MILLISECONDS, - new PriorityBlockingQueue(), - new DefaultThreadFactory(name, uncaughtThrowableStrategy, preventNetworkOperations)); + ThreadFactory factory = + new DefaultThreadFactory( + threadFactory, name, uncaughtThrowableStrategy, preventNetworkOperations); + ThreadPoolExecutor executor; + if (onExecuteDecorator != null) { + executor = + new ThreadPoolExecutor( + corePoolSize, + maximumPoolSize, + /* keepAliveTime= */ threadTimeoutMillis, + TimeUnit.MILLISECONDS, + new PriorityBlockingQueue<>(), + factory) { + @Override + public void execute(@NonNull Runnable command) { + super.execute(onExecuteDecorator.apply(command)); + } + }; + } else { + executor = + new ThreadPoolExecutor( + corePoolSize, + maximumPoolSize, + /* keepAliveTime= */ threadTimeoutMillis, + TimeUnit.MILLISECONDS, + new PriorityBlockingQueue<>(), + factory); + } if (threadTimeoutMillis != NO_THREAD_TIMEOUT) { executor.allowCoreThreadTimeOut(true); diff --git a/library/src/main/java/com/bumptech/glide/load/model/AssetUriLoader.java b/library/src/main/java/com/bumptech/glide/load/model/AssetUriLoader.java index 3da79ce851..321cec12c1 100644 --- a/library/src/main/java/com/bumptech/glide/load/model/AssetUriLoader.java +++ b/library/src/main/java/com/bumptech/glide/load/model/AssetUriLoader.java @@ -1,9 +1,9 @@ package com.bumptech.glide.load.model; import android.content.ContentResolver; +import android.content.res.AssetFileDescriptor; import android.content.res.AssetManager; import android.net.Uri; -import android.os.ParcelFileDescriptor; import androidx.annotation.NonNull; import com.bumptech.glide.load.Options; import com.bumptech.glide.load.data.DataFetcher; @@ -83,10 +83,10 @@ public DataFetcher buildFetcher(AssetManager assetManager, String a } } - /** Factory for loading {@link ParcelFileDescriptor}s from asset manager Uris. */ + /** Factory for loading {@link AssetFileDescriptor}s from asset manager Uris. */ public static class FileDescriptorFactory - implements ModelLoaderFactory, - AssetFetcherFactory { + implements ModelLoaderFactory, + AssetFetcherFactory { private final AssetManager assetManager; @@ -96,7 +96,7 @@ public FileDescriptorFactory(AssetManager assetManager) { @NonNull @Override - public ModelLoader build(MultiModelLoaderFactory multiFactory) { + public ModelLoader build(MultiModelLoaderFactory multiFactory) { return new AssetUriLoader<>(assetManager, this); } @@ -106,7 +106,7 @@ public void teardown() { } @Override - public DataFetcher buildFetcher( + public DataFetcher buildFetcher( AssetManager assetManager, String assetPath) { return new FileDescriptorAssetPathFetcher(assetManager, assetPath); } diff --git a/library/src/main/java/com/bumptech/glide/load/model/DirectResourceLoader.java b/library/src/main/java/com/bumptech/glide/load/model/DirectResourceLoader.java new file mode 100644 index 0000000000..4a356d161a --- /dev/null +++ b/library/src/main/java/com/bumptech/glide/load/model/DirectResourceLoader.java @@ -0,0 +1,259 @@ +package com.bumptech.glide.load.model; + +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.content.res.Resources; +import android.content.res.Resources.Theme; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +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.resource.drawable.DrawableDecoderCompat; +import com.bumptech.glide.load.resource.drawable.ResourceDrawableDecoder; +import com.bumptech.glide.signature.ObjectKey; +import java.io.IOException; +import java.io.InputStream; + +/** + * Loads themed resource ids using {@link Resources#openRawResource(int)} or {@link + * Resources#openRawResourceFd(int)} using the theme from {@link ResourceDrawableDecoder#THEME} when + * it's available. + * + *

Resource ids from other packages are handled by {@link ResourceLoader} via {@link + * ResourceDrawableDecoder} and {@link + * com.bumptech.glide.load.resource.bitmap.ResourceBitmapDecoder}. + * + * @param The type of data this {@code ModelLoader} will produce (e.g. {@link InputStream}, + * {@link AssetFileDescriptor} etc). + */ +public final class DirectResourceLoader implements ModelLoader { + + private final Context context; + private final ResourceOpener resourceOpener; + + public static ModelLoaderFactory inputStreamFactory(Context context) { + return new InputStreamFactory(context); + } + + public static ModelLoaderFactory assetFileDescriptorFactory( + Context context) { + return new AssetFileDescriptorFactory(context); + } + + public static ModelLoaderFactory drawableFactory(Context context) { + return new DrawableFactory(context); + } + + DirectResourceLoader(Context context, ResourceOpener resourceOpener) { + this.context = context.getApplicationContext(); + this.resourceOpener = resourceOpener; + } + + @Override + public LoadData buildLoadData( + @NonNull Integer resourceId, int width, int height, @NonNull Options options) { + Theme theme = options.get(ResourceDrawableDecoder.THEME); + Resources resources = + Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP && theme != null + ? theme.getResources() + : context.getResources(); + return new LoadData<>( + // TODO(judds): We try to apply AndroidResourceSignature for caching in RequestBuilder. + // Arguably we should mix in that information here instead. + new ObjectKey(resourceId), + new ResourceDataFetcher<>(theme, resources, resourceOpener, resourceId)); + } + + @Override + public boolean handles(@NonNull Integer integer) { + // We could check that this is in fact a resource ID, but doing so isn't free and in practice + // it doesn't seem to have been an issue historically. + return true; + } + + private interface ResourceOpener { + + /** + * {@code resources} is expected to come from the given {@code theme}, so {@code theme} does not + * need to be used if it's not required. + */ + DataT open(@Nullable Theme theme, Resources resources, int resourceId); + + void close(DataT data) throws IOException; + + Class getDataClass(); + } + + private static final class AssetFileDescriptorFactory + implements ModelLoaderFactory, + ResourceOpener { + + private final Context context; + + AssetFileDescriptorFactory(Context context) { + this.context = context; + } + + @Override + public AssetFileDescriptor open(@Nullable Theme theme, Resources resources, int resourceId) { + return resources.openRawResourceFd(resourceId); + } + + @Override + public void close(AssetFileDescriptor data) throws IOException { + data.close(); + } + + @Override + public Class getDataClass() { + return AssetFileDescriptor.class; + } + + @NonNull + @Override + public ModelLoader build( + @NonNull MultiModelLoaderFactory multiFactory) { + return new DirectResourceLoader<>(context, this); + } + + @Override + public void teardown() {} + } + + private static final class InputStreamFactory + implements ModelLoaderFactory, ResourceOpener { + + private final Context context; + + InputStreamFactory(Context context) { + this.context = context; + } + + @NonNull + @Override + public ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new DirectResourceLoader<>(context, this); + } + + @Override + public InputStream open(@Nullable Theme theme, Resources resources, int resourceId) { + return resources.openRawResource(resourceId); + } + + @Override + public void close(InputStream data) throws IOException { + data.close(); + } + + @Override + public Class getDataClass() { + return InputStream.class; + } + + @Override + public void teardown() {} + } + + /** + * Handles vectors, shapes and other resources that cannot be opened with + * Resources.openRawResource. Overlaps in functionality with {@link ResourceDrawableDecoder} and + * {@link com.bumptech.glide.load.resource.bitmap.ResourceBitmapDecoder} but it's more efficient + * for simple resource loads within a single application. + */ + private static final class DrawableFactory + implements ModelLoaderFactory, ResourceOpener { + + private final Context context; + + DrawableFactory(Context context) { + this.context = context; + } + + @Override + public Drawable open(@Nullable Theme theme, Resources resources, int resourceId) { + // The Resources already includes the theme provided with the request, so we don't need to + // provide the theme separately. + return DrawableDecoderCompat.getDrawable(context, resourceId, theme); + } + + @Override + public void close(Drawable data) throws IOException {} + + @Override + public Class getDataClass() { + return Drawable.class; + } + + @NonNull + @Override + public ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new DirectResourceLoader<>(context, this); + } + + @Override + public void teardown() {} + } + + private static final class ResourceDataFetcher implements DataFetcher { + + @Nullable private final Theme theme; + private final Resources resources; + private final ResourceOpener resourceOpener; + private final int resourceId; + @Nullable private DataT data; + + ResourceDataFetcher( + @Nullable Theme theme, + Resources resources, + ResourceOpener resourceOpener, + int resourceId) { + this.theme = theme; + this.resources = resources; + this.resourceOpener = resourceOpener; + this.resourceId = resourceId; + } + + @Override + public void loadData( + @NonNull Priority priority, @NonNull DataCallback callback) { + try { + data = resourceOpener.open(theme, resources, resourceId); + callback.onDataReady(data); + } catch (Resources.NotFoundException e) { + callback.onLoadFailed(e); + } + } + + @Override + public void cleanup() { + DataT local = data; + if (local != null) { + try { + resourceOpener.close(local); + } catch (IOException e) { + // Ignored. + } + } + } + + @Override + public void cancel() {} + + @NonNull + @Override + public Class getDataClass() { + return resourceOpener.getDataClass(); + } + + @NonNull + @Override + public DataSource getDataSource() { + return DataSource.LOCAL; + } + } +} diff --git a/library/src/main/java/com/bumptech/glide/load/model/GlideUrl.java b/library/src/main/java/com/bumptech/glide/load/model/GlideUrl.java index 9d4a621eff..66052604f2 100644 --- a/library/src/main/java/com/bumptech/glide/load/model/GlideUrl.java +++ b/library/src/main/java/com/bumptech/glide/load/model/GlideUrl.java @@ -26,7 +26,7 @@ * convenience. */ public class GlideUrl implements Key { - private static final String ALLOWED_URI_CHARS = "@#&=*+-_.,:!?()/~'%;$"; + private static final String ALLOWED_URI_CHARS = "@#&=*+-_.,:!?()/~'%;$[]"; private final Headers headers; @Nullable private final URL url; @Nullable private final String stringUrl; diff --git a/library/src/main/java/com/bumptech/glide/load/model/ModelLoader.java b/library/src/main/java/com/bumptech/glide/load/model/ModelLoader.java index 324d7929cc..00bd4bc311 100644 --- a/library/src/main/java/com/bumptech/glide/load/model/ModelLoader.java +++ b/library/src/main/java/com/bumptech/glide/load/model/ModelLoader.java @@ -86,7 +86,7 @@ LoadData buildLoadData( /** * Returns true if the given model is a of a recognized type that this loader can probably load. * - *

For example, you may want multiple Uri -> InputStream loaders. One might handle media store + *

For example, you may want multiple Uri to InputStream loaders. One might handle media store * Uris, another might handle asset Uris, and a third might handle file Uris etc. * *

This method is generally expected to do no I/O and complete quickly, so best effort results diff --git a/library/src/main/java/com/bumptech/glide/load/model/MultiModelLoaderFactory.java b/library/src/main/java/com/bumptech/glide/load/model/MultiModelLoaderFactory.java index 4df5ac65c9..eed163c68e 100644 --- a/library/src/main/java/com/bumptech/glide/load/model/MultiModelLoaderFactory.java +++ b/library/src/main/java/com/bumptech/glide/load/model/MultiModelLoaderFactory.java @@ -43,14 +43,14 @@ synchronized void append( @NonNull Class modelClass, @NonNull Class dataClass, @NonNull ModelLoaderFactory factory) { - add(modelClass, dataClass, factory, /*append=*/ true); + add(modelClass, dataClass, factory, /* append= */ true); } synchronized void prepend( @NonNull Class modelClass, @NonNull Class dataClass, @NonNull ModelLoaderFactory factory) { - add(modelClass, dataClass, factory, /*append=*/ false); + add(modelClass, dataClass, factory, /* append= */ false); } private void add( diff --git a/library/src/main/java/com/bumptech/glide/load/model/ResourceLoader.java b/library/src/main/java/com/bumptech/glide/load/model/ResourceLoader.java index 2c9087c27d..ad48104ba6 100644 --- a/library/src/main/java/com/bumptech/glide/load/model/ResourceLoader.java +++ b/library/src/main/java/com/bumptech/glide/load/model/ResourceLoader.java @@ -15,6 +15,14 @@ * A model loader for handling Android resource files. Model must be an Android resource id in the * package of the given context. * + *

This class should always be less preferred than {@link DirectResourceLoader} because {@link + * DirectResourceLoader} is more efficient for {@code Drawables} owned by this package. This class + * only handles passing through {@link Uri}s to {@link + * com.bumptech.glide.load.resource.drawable.ResourceDrawableDecoder} and {@link + * com.bumptech.glide.load.resource.bitmap.ResourceBitmapDecoder}. Those classes can handle assets + * from other applications, but are not as efficient as {@link DirectResourceLoader} for assets + * owned by this package. + * * @param The type of data that will be loaded for the given android resource. */ public class ResourceLoader implements ModelLoader { @@ -44,9 +52,7 @@ private Uri getResourceUri(Integer model) { + "://" + resources.getResourcePackageName(model) + '/' - + resources.getResourceTypeName(model) - + '/' - + resources.getResourceEntryName(model)); + + model); } catch (Resources.NotFoundException e) { if (Log.isLoggable(TAG, Log.WARN)) { Log.w(TAG, "Received invalid resource id: " + model, e); @@ -82,7 +88,14 @@ public void teardown() { } } - /** Factory for loading {@link ParcelFileDescriptor}s from Android resource ids. */ + /** + * Factory for loading {@link ParcelFileDescriptor}s from Android resource ids. + * + * @deprecated This class is unused by Glide. {@link AssetFileDescriptorFactory} should be + * preferred because it's not possible to reliably load a simple {@link + * java.io.FileDescriptor} for resources. + */ + @Deprecated public static class FileDescriptorFactory implements ModelLoaderFactory { diff --git a/library/src/main/java/com/bumptech/glide/load/model/ResourceUriLoader.java b/library/src/main/java/com/bumptech/glide/load/model/ResourceUriLoader.java new file mode 100644 index 0000000000..7d2ba90801 --- /dev/null +++ b/library/src/main/java/com/bumptech/glide/load/model/ResourceUriLoader.java @@ -0,0 +1,164 @@ +package com.bumptech.glide.load.model; + +import android.annotation.SuppressLint; +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.net.Uri; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.bumptech.glide.load.Options; +import java.io.InputStream; +import java.util.List; + +/** + * Converts Resource Uris to resource ids if the resource Uri points to a resource in this package. + * + *

This class works by parsing Uris into resource ids, then delegating the resource ID load to + * other {@link ModelLoader}s, typically {@link DirectResourceLoader}. + * + *

This class really shouldn't need to exist. If you need to load resources, just pass in the + * integer resource id directly using {@link com.bumptech.glide.RequestManager#load(Integer)} + * instead. It'll be more correct in terms of caching and more efficient to load. The only reason + * we're supporting this case is for backwards compatibility. + * + *

Because this class explicitly only handles resource Uris that are from the application's + * package, resource uris from other packages are handled by {@link UriLoader}. {@link UriLoader} is + * even less preferred because it can only handle certain resources from raw resources and it will + * not apply appropriate theming, RTL or night mode attributes. + * + * @param The type of data produced, e.g. {@link InputStream} or {@link + * AssetFileDescriptor}. + */ +public final class ResourceUriLoader implements ModelLoader { + /** + * See the javadoc on {@link android.content.res.Resources#getIdentifier(java.lang.String, + * java.lang.String, java.lang.String)}. + */ + private static final int INVALID_RESOURCE_ID = 0; + + private static final String TAG = "ResourceUriLoader"; + + private final Context context; + private final ModelLoader delegate; + + public static ModelLoaderFactory newStreamFactory(Context context) { + return new InputStreamFactory(context); + } + + public static ModelLoaderFactory newAssetFileDescriptorFactory( + Context context) { + return new AssetFileDescriptorFactory(context); + } + + ResourceUriLoader(Context context, ModelLoader delegate) { + this.context = context.getApplicationContext(); + this.delegate = delegate; + } + + @Nullable + @Override + public LoadData buildLoadData( + @NonNull Uri uri, int width, int height, @NonNull Options options) { + List pathSegments = uri.getPathSegments(); + // android.resource/// + if (pathSegments.size() == 1) { + return parseResourceIdUri(uri, width, height, options); + } + // android.resource//// + if (pathSegments.size() == 2) { + return parseResourceNameUri(uri, width, height, options); + } + if (Log.isLoggable(TAG, Log.WARN)) { + Log.w(TAG, "Failed to parse resource uri: " + uri); + } + return null; + } + + @Nullable + private LoadData parseResourceNameUri( + @NonNull Uri uri, int width, int height, @NonNull Options options) { + List pathSegments = uri.getPathSegments(); + String resourceType = pathSegments.get(0); + String resourceName = pathSegments.get(1); + + // Yes it's bad, but the caller has chosen to give us a resource uri... + @SuppressLint("DiscouragedApi") + int identifier = + context.getResources().getIdentifier(resourceName, resourceType, context.getPackageName()); + if (identifier == INVALID_RESOURCE_ID) { + if (Log.isLoggable(TAG, Log.WARN)) { + Log.w(TAG, "Failed to find resource id for: " + uri); + } + return null; + } + + return delegate.buildLoadData(identifier, width, height, options); + } + + @Nullable + private LoadData parseResourceIdUri( + @NonNull Uri uri, int width, int height, @NonNull Options options) { + try { + int resourceId = Integer.parseInt(uri.getPathSegments().get(0)); + if (resourceId == INVALID_RESOURCE_ID) { + if (Log.isLoggable(TAG, Log.WARN)) { + Log.w(TAG, "Failed to parse a valid non-0 resource id from: " + uri); + } + return null; + } + return delegate.buildLoadData(resourceId, width, height, options); + } catch (NumberFormatException e) { + if (Log.isLoggable(TAG, Log.WARN)) { + Log.w(TAG, "Failed to parse resource id from: " + uri, e); + } + } + return null; + } + + @Override + public boolean handles(@NonNull Uri uri) { + return ContentResolver.SCHEME_ANDROID_RESOURCE.equals(uri.getScheme()) + && context.getPackageName().equals(uri.getAuthority()); + } + + private static final class InputStreamFactory implements ModelLoaderFactory { + + private final Context context; + + InputStreamFactory(Context context) { + this.context = context; + } + + @NonNull + @Override + public ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new ResourceUriLoader<>(context, multiFactory.build(Integer.class, InputStream.class)); + } + + @Override + public void teardown() {} + } + + private static final class AssetFileDescriptorFactory + implements ModelLoaderFactory { + + private final Context context; + + AssetFileDescriptorFactory(Context context) { + this.context = context; + } + + @NonNull + @Override + public ModelLoader build( + @NonNull MultiModelLoaderFactory multiFactory) { + return new ResourceUriLoader<>( + context, multiFactory.build(Integer.class, AssetFileDescriptor.class)); + } + + @Override + public void teardown() {} + } +} diff --git a/library/src/main/java/com/bumptech/glide/load/model/UnitModelLoader.java b/library/src/main/java/com/bumptech/glide/load/model/UnitModelLoader.java index 043241d068..8861be5b46 100644 --- a/library/src/main/java/com/bumptech/glide/load/model/UnitModelLoader.java +++ b/library/src/main/java/com/bumptech/glide/load/model/UnitModelLoader.java @@ -22,7 +22,9 @@ public static UnitModelLoader getInstance() { return (UnitModelLoader) INSTANCE; } - /** @deprecated Use {@link #getInstance()} instead. */ + /** + * @deprecated Use {@link #getInstance()} instead. + */ // Need constructor to document deprecation, will be removed, when constructor is privatized. @SuppressWarnings({"PMD.UnnecessaryConstructor", "DeprecatedIsStillUsed"}) @Deprecated @@ -95,7 +97,9 @@ public static Factory getInstance() { return (Factory) FACTORY; } - /** @deprecated Use {@link #getInstance()} instead. */ + /** + * @deprecated Use {@link #getInstance()} instead. + */ // Need constructor to document deprecation, will be removed, when constructor is privatized. @SuppressWarnings("PMD.UnnecessaryConstructor") @Deprecated diff --git a/library/src/main/java/com/bumptech/glide/load/model/UriLoader.java b/library/src/main/java/com/bumptech/glide/load/model/UriLoader.java index 543ee9dbc2..e99f5cbbca 100644 --- a/library/src/main/java/com/bumptech/glide/load/model/UriLoader.java +++ b/library/src/main/java/com/bumptech/glide/load/model/UriLoader.java @@ -26,13 +26,14 @@ * @param The type of data that will be retrieved for {@link android.net.Uri}s. */ public class UriLoader implements ModelLoader { + private static final Set SCHEMES = Collections.unmodifiableSet( new HashSet<>( Arrays.asList( ContentResolver.SCHEME_FILE, - ContentResolver.SCHEME_ANDROID_RESOURCE, - ContentResolver.SCHEME_CONTENT))); + ContentResolver.SCHEME_CONTENT, + ContentResolver.SCHEME_ANDROID_RESOURCE))); private final LocalUriFetcherFactory factory; @@ -67,14 +68,24 @@ public static class StreamFactory implements ModelLoaderFactory, LocalUriFetcherFactory { private final ContentResolver contentResolver; + private final boolean useMediaStoreApisIfAvailable; public StreamFactory(ContentResolver contentResolver) { + this(contentResolver, /* useMediaStoreApisIfAvailable */ false); + } + + /** + * useMediaStoreApisIfAvailable is part of an experiment and the constructor can be removed in a + * future version. + */ + public StreamFactory(ContentResolver contentResolver, boolean useMediaStoreApisIfAvailable) { this.contentResolver = contentResolver; + this.useMediaStoreApisIfAvailable = useMediaStoreApisIfAvailable; } @Override public DataFetcher build(Uri uri) { - return new StreamLocalUriFetcher(contentResolver, uri); + return new StreamLocalUriFetcher(contentResolver, uri, useMediaStoreApisIfAvailable); } @NonNull @@ -95,14 +106,25 @@ public static class FileDescriptorFactory LocalUriFetcherFactory { private final ContentResolver contentResolver; + private final boolean useMediaStoreApisIfAvailable; public FileDescriptorFactory(ContentResolver contentResolver) { + this(contentResolver, /* useMediaStoreApisIfAvailable */ false); + } + + /** + * useMediaStoreApisIfAvailable is part of an experiment and the constructor can be removed in a + * future version. + */ + public FileDescriptorFactory( + ContentResolver contentResolver, boolean useMediaStoreApisIfAvailable) { this.contentResolver = contentResolver; + this.useMediaStoreApisIfAvailable = useMediaStoreApisIfAvailable; } @Override public DataFetcher build(Uri uri) { - return new FileDescriptorLocalUriFetcher(contentResolver, uri); + return new FileDescriptorLocalUriFetcher(contentResolver, uri, useMediaStoreApisIfAvailable); } @NonNull @@ -123,9 +145,20 @@ public static final class AssetFileDescriptorFactory LocalUriFetcherFactory { private final ContentResolver contentResolver; + private final boolean useMediaStoreApisIfAvailable; public AssetFileDescriptorFactory(ContentResolver contentResolver) { + this(contentResolver, /* useMediaStoreApisIfAvailable */ false); + } + + /** + * useMediaStoreApisIfAvailable is part of an experiment and the constructor can be removed in a + * future version. + */ + public AssetFileDescriptorFactory( + ContentResolver contentResolver, boolean useMediaStoreApisIfAvailable) { this.contentResolver = contentResolver; + this.useMediaStoreApisIfAvailable = useMediaStoreApisIfAvailable; } @Override @@ -140,7 +173,8 @@ public void teardown() { @Override public DataFetcher build(Uri uri) { - return new AssetFileDescriptorLocalUriFetcher(contentResolver, uri); + return new AssetFileDescriptorLocalUriFetcher( + contentResolver, uri, useMediaStoreApisIfAvailable); } } } diff --git a/library/src/main/java/com/bumptech/glide/load/model/stream/QMediaStoreUriLoader.java b/library/src/main/java/com/bumptech/glide/load/model/stream/QMediaStoreUriLoader.java index 4cce1b6cea..9862fe0e66 100644 --- a/library/src/main/java/com/bumptech/glide/load/model/stream/QMediaStoreUriLoader.java +++ b/library/src/main/java/com/bumptech/glide/load/model/stream/QMediaStoreUriLoader.java @@ -34,14 +34,15 @@ * to get at the un-redacted File. There are two ways we can do so: * *

    - *
  • MediaStore.setRequireOriginal + *
  • MediaStore.setRequireOriginal (on Android Q) *
  • Querying for and opening the file via the underlying file path, rather than via {@code * ContentResolver} *
* - *

MediaStore.setRequireOriginal will only work for applications that target Q and request and - * currently have {@link android.Manifest.permission#ACCESS_MEDIA_LOCATION}. It's the simplest - * change to make, but it covers the fewest applications. + *

On Android Q, Glide uses {@code MediaStore.setRequireOriginal} to bypass redaction + * automatically if the application has {@link android.Manifest.permission#ACCESS_MEDIA_LOCATION}. + * On Android R and above, Glide expects the caller to handle adding the requireOriginal parameter + * to the URI themselves if they want un-redacted access. * *

Querying for the file path and opening the file directly works for applications that do not * target Q and for applications that do target Q but that opt in to legacy storage mode. Other @@ -156,6 +157,13 @@ private LoadData buildDelegateData() throws FileNotFoundException { if (Environment.isExternalStorageLegacy()) { return fileDelegate.buildLoadData(queryForFilePath(uri), width, height, options); } else { + // On Android R and above, do not append requireOriginal. + // For Android Q, Android Picker uris have MediaStore authority and do not accept + // requireOriginal. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + || MediaStoreUtil.isAndroidPickerUri(uri)) { + return uriDelegate.buildLoadData(uri, width, height, options); + } Uri toLoad = isAccessMediaLocationGranted() ? MediaStore.setRequireOriginal(uri) : uri; return uriDelegate.buildLoadData(toLoad, width, height, options); } @@ -200,9 +208,9 @@ private File queryForFilePath(Uri uri) throws FileNotFoundException { .query( uri, PROJECTION, - /*selection=*/ null, - /*selectionArgs=*/ null, - /*sortOrder=*/ null); + /* selection= */ null, + /* selectionArgs= */ null, + /* sortOrder= */ null); if (cursor == null || !cursor.moveToFirst()) { throw new FileNotFoundException("Failed to media store entry for: " + uri); } diff --git a/library/src/main/java/com/bumptech/glide/load/resource/DefaultOnHeaderDecodedListener.java b/library/src/main/java/com/bumptech/glide/load/resource/DefaultOnHeaderDecodedListener.java new file mode 100644 index 0000000000..0faa1cba75 --- /dev/null +++ b/library/src/main/java/com/bumptech/glide/load/resource/DefaultOnHeaderDecodedListener.java @@ -0,0 +1,130 @@ +package com.bumptech.glide.load.resource; + +import android.graphics.ColorSpace; +import android.graphics.ImageDecoder; +import android.graphics.ImageDecoder.DecodeException; +import android.graphics.ImageDecoder.ImageInfo; +import android.graphics.ImageDecoder.OnHeaderDecodedListener; +import android.graphics.ImageDecoder.OnPartialImageListener; +import android.graphics.ImageDecoder.Source; +import android.os.Build; +import android.util.Log; +import android.util.Size; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import com.bumptech.glide.load.DecodeFormat; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.PreferredColorSpace; +import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy; +import com.bumptech.glide.load.resource.bitmap.Downsampler; +import com.bumptech.glide.load.resource.bitmap.HardwareConfigState; +import com.bumptech.glide.request.target.Target; +import com.bumptech.glide.util.Synthetic; + +/** + * Downsamples, decodes, and rotates images according to their exif orientation using {@link + * ImageDecoder}. + * + *

Obeys all options in {@link Downsampler} except for {@link + * Downsampler#FIX_BITMAP_SIZE_TO_REQUESTED_DIMENSIONS}. + */ +@RequiresApi(api = 28) +public final class DefaultOnHeaderDecodedListener implements OnHeaderDecodedListener { + private static final String TAG = "ImageDecoder"; + + @Synthetic + private final HardwareConfigState hardwareConfigState = HardwareConfigState.getInstance(); + + private final int requestedWidth; + private final int requestedHeight; + private final DecodeFormat decodeFormat; + private final DownsampleStrategy strategy; + private final boolean isHardwareConfigAllowed; + private final PreferredColorSpace preferredColorSpace; + + public DefaultOnHeaderDecodedListener( + int requestedWidth, int requestedHeight, @NonNull Options options) { + this.requestedWidth = requestedWidth; + this.requestedHeight = requestedHeight; + decodeFormat = options.get(Downsampler.DECODE_FORMAT); + strategy = options.get(DownsampleStrategy.OPTION); + isHardwareConfigAllowed = + options.get(Downsampler.ALLOW_HARDWARE_CONFIG) != null + && options.get(Downsampler.ALLOW_HARDWARE_CONFIG); + preferredColorSpace = options.get(Downsampler.PREFERRED_COLOR_SPACE); + } + + @Override + public void onHeaderDecoded( + @NonNull ImageDecoder decoder, @NonNull ImageInfo info, @NonNull Source source) { + if (hardwareConfigState.isHardwareConfigAllowed( + requestedWidth, + requestedHeight, + isHardwareConfigAllowed, + /* isExifOrientationRequired= */ false)) { + decoder.setAllocator(ImageDecoder.ALLOCATOR_HARDWARE); + } else { + decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE); + } + + if (decodeFormat == DecodeFormat.PREFER_RGB_565) { + decoder.setMemorySizePolicy(ImageDecoder.MEMORY_POLICY_LOW_RAM); + } + + decoder.setOnPartialImageListener( + new OnPartialImageListener() { + @Override + public boolean onPartialImage(@NonNull DecodeException e) { + // Never return partial images. + return false; + } + }); + + Size size = info.getSize(); + int targetWidth = requestedWidth; + if (requestedWidth == Target.SIZE_ORIGINAL) { + targetWidth = size.getWidth(); + } + int targetHeight = requestedHeight; + if (requestedHeight == Target.SIZE_ORIGINAL) { + targetHeight = size.getHeight(); + } + + float scaleFactor = + strategy.getScaleFactor(size.getWidth(), size.getHeight(), targetWidth, targetHeight); + + int resizeWidth = Math.round(scaleFactor * size.getWidth()); + int resizeHeight = Math.round(scaleFactor * size.getHeight()); + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v( + TAG, + "Resizing" + + " from [" + + size.getWidth() + + "x" + + size.getHeight() + + "]" + + " to [" + + resizeWidth + + "x" + + resizeHeight + + "]" + + " scaleFactor: " + + scaleFactor); + } + + decoder.setTargetSize(resizeWidth, resizeHeight); + if (preferredColorSpace != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + boolean isP3Eligible = + preferredColorSpace == PreferredColorSpace.DISPLAY_P3 + && info.getColorSpace() != null + && info.getColorSpace().isWideGamut(); + decoder.setTargetColorSpace( + ColorSpace.get(isP3Eligible ? ColorSpace.Named.DISPLAY_P3 : ColorSpace.Named.SRGB)); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB)); + } + } + } +} diff --git a/library/src/main/java/com/bumptech/glide/load/resource/ImageDecoderResourceDecoder.java b/library/src/main/java/com/bumptech/glide/load/resource/ImageDecoderResourceDecoder.java deleted file mode 100644 index 69be855367..0000000000 --- a/library/src/main/java/com/bumptech/glide/load/resource/ImageDecoderResourceDecoder.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.bumptech.glide.load.resource; - -import android.annotation.SuppressLint; -import android.graphics.ColorSpace; -import android.graphics.ImageDecoder; -import android.graphics.ImageDecoder.DecodeException; -import android.graphics.ImageDecoder.ImageInfo; -import android.graphics.ImageDecoder.OnHeaderDecodedListener; -import android.graphics.ImageDecoder.OnPartialImageListener; -import android.graphics.ImageDecoder.Source; -import android.os.Build; -import android.util.Log; -import android.util.Size; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import com.bumptech.glide.load.DecodeFormat; -import com.bumptech.glide.load.Options; -import com.bumptech.glide.load.PreferredColorSpace; -import com.bumptech.glide.load.ResourceDecoder; -import com.bumptech.glide.load.engine.Resource; -import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy; -import com.bumptech.glide.load.resource.bitmap.Downsampler; -import com.bumptech.glide.load.resource.bitmap.HardwareConfigState; -import com.bumptech.glide.request.target.Target; -import com.bumptech.glide.util.Synthetic; -import java.io.IOException; - -/** - * Downsamples, decodes, and rotates images according to their exif orientation using {@link - * ImageDecoder}. - * - *

Obeys all options in {@link Downsampler} except for {@link - * Downsampler#FIX_BITMAP_SIZE_TO_REQUESTED_DIMENSIONS}. - * - * @param The type of resource to be decoded (Bitmap, Drawable etc). - */ -@RequiresApi(api = 28) -public abstract class ImageDecoderResourceDecoder implements ResourceDecoder { - private static final String TAG = "ImageDecoder"; - - @SuppressWarnings("WeakerAccess") - @Synthetic - final HardwareConfigState hardwareConfigState = HardwareConfigState.getInstance(); - - @Override - public final boolean handles(@NonNull Source source, @NonNull Options options) { - return true; - } - - @Nullable - @Override - public final Resource decode( - @NonNull Source source, - final int requestedWidth, - final int requestedHeight, - @NonNull Options options) - throws IOException { - final DecodeFormat decodeFormat = options.get(Downsampler.DECODE_FORMAT); - final DownsampleStrategy strategy = options.get(DownsampleStrategy.OPTION); - final boolean isHardwareConfigAllowed = - options.get(Downsampler.ALLOW_HARDWARE_CONFIG) != null - && options.get(Downsampler.ALLOW_HARDWARE_CONFIG); - final PreferredColorSpace preferredColorSpace = options.get(Downsampler.PREFERRED_COLOR_SPACE); - - return decode( - source, - requestedWidth, - requestedHeight, - new OnHeaderDecodedListener() { - @SuppressLint("Override") - @Override - public void onHeaderDecoded(ImageDecoder decoder, ImageInfo info, Source source) { - if (hardwareConfigState.isHardwareConfigAllowed( - requestedWidth, - requestedHeight, - isHardwareConfigAllowed, - /*isExifOrientationRequired=*/ false)) { - decoder.setAllocator(ImageDecoder.ALLOCATOR_HARDWARE); - } else { - decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE); - } - - if (decodeFormat == DecodeFormat.PREFER_RGB_565) { - decoder.setMemorySizePolicy(ImageDecoder.MEMORY_POLICY_LOW_RAM); - } - - decoder.setOnPartialImageListener( - new OnPartialImageListener() { - @Override - public boolean onPartialImage(@NonNull DecodeException e) { - // Never return partial images. - return false; - } - }); - - Size size = info.getSize(); - int targetWidth = requestedWidth; - if (requestedWidth == Target.SIZE_ORIGINAL) { - targetWidth = size.getWidth(); - } - int targetHeight = requestedHeight; - if (requestedHeight == Target.SIZE_ORIGINAL) { - targetHeight = size.getHeight(); - } - - float scaleFactor = - strategy.getScaleFactor( - size.getWidth(), size.getHeight(), targetWidth, targetHeight); - - int resizeWidth = Math.round(scaleFactor * size.getWidth()); - int resizeHeight = Math.round(scaleFactor * size.getHeight()); - if (Log.isLoggable(TAG, Log.VERBOSE)) { - Log.v( - TAG, - "Resizing" - + " from [" - + size.getWidth() - + "x" - + size.getHeight() - + "]" - + " to [" - + resizeWidth - + "x" - + resizeHeight - + "]" - + " scaleFactor: " - + scaleFactor); - } - - decoder.setTargetSize(resizeWidth, resizeHeight); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - boolean isP3Eligible = - preferredColorSpace == PreferredColorSpace.DISPLAY_P3 - && info.getColorSpace() != null - && info.getColorSpace().isWideGamut(); - decoder.setTargetColorSpace( - ColorSpace.get( - isP3Eligible ? ColorSpace.Named.DISPLAY_P3 : ColorSpace.Named.SRGB)); - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB)); - } - } - }); - } - - protected abstract Resource decode( - Source source, int requestedWidth, int requestedHeight, OnHeaderDecodedListener listener) - throws IOException; -} diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapDrawableTransformation.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapDrawableTransformation.java index 8e1ce63c38..48ef046e3e 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapDrawableTransformation.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapDrawableTransformation.java @@ -24,7 +24,7 @@ public class BitmapDrawableTransformation implements Transformation wrapped) { this.wrapped = - Preconditions.checkNotNull(new DrawableTransformation(wrapped, /*isRequired=*/ false)); + Preconditions.checkNotNull(new DrawableTransformation(wrapped, /* isRequired= */ false)); } @NonNull diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapEncoder.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapEncoder.java index 4f948c28f6..5117909107 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapEncoder.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapEncoder.java @@ -56,7 +56,9 @@ public BitmapEncoder(@NonNull ArrayPool arrayPool) { this.arrayPool = arrayPool; } - /** @deprecated Use {@link #BitmapEncoder(ArrayPool)} instead. */ + /** + * @deprecated Use {@link #BitmapEncoder(ArrayPool)} instead. + */ @Deprecated public BitmapEncoder() { arrayPool = null; diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapImageDecoderResourceDecoder.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapImageDecoderResourceDecoder.java index 284dd769cb..48926d876e 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapImageDecoderResourceDecoder.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapImageDecoderResourceDecoder.java @@ -2,30 +2,35 @@ import android.graphics.Bitmap; import android.graphics.ImageDecoder; -import android.graphics.ImageDecoder.OnHeaderDecodedListener; import android.graphics.ImageDecoder.Source; import android.util.Log; +import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; import com.bumptech.glide.load.engine.Resource; import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPoolAdapter; -import com.bumptech.glide.load.resource.ImageDecoderResourceDecoder; +import com.bumptech.glide.load.resource.DefaultOnHeaderDecodedListener; import java.io.IOException; -/** {@link Bitmap} specific implementation of {@link ImageDecoderResourceDecoder}. */ +/** {@link Bitmap} specific implementation of {@link DefaultOnHeaderDecodedListener}. */ @RequiresApi(api = 28) -public final class BitmapImageDecoderResourceDecoder extends ImageDecoderResourceDecoder { +public final class BitmapImageDecoderResourceDecoder implements ResourceDecoder { private static final String TAG = "BitmapImageDecoder"; private final BitmapPool bitmapPool = new BitmapPoolAdapter(); @Override - protected Resource decode( - Source source, - int requestedResourceWidth, - int requestedResourceHeight, - OnHeaderDecodedListener listener) - throws IOException { - Bitmap result = ImageDecoder.decodeBitmap(source, listener); + public boolean handles(@NonNull Source source, @NonNull Options options) throws IOException { + return true; + } + + @Override + public Resource decode( + @NonNull Source source, int width, int height, @NonNull Options options) throws IOException { + Bitmap result = + ImageDecoder.decodeBitmap( + source, new DefaultOnHeaderDecodedListener(width, height, options)); if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v( TAG, @@ -36,9 +41,9 @@ protected Resource decode( + result.getHeight() + "]" + " for [" - + requestedResourceWidth + + width + "x" - + requestedResourceHeight + + height + "]"); } return new BitmapResource(result, bitmapPool); diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapTransformation.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapTransformation.java index e5b0a90da4..3b69cd0aa3 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapTransformation.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapTransformation.java @@ -19,8 +19,7 @@ * *

Use cases will look something like this: * - *

- * 
+ * 
{@code
  * public class FillSpace extends BitmapTransformation {
  *     private static final String ID = "com.bumptech.glide.transformations.FillSpace";
  *     private static final byte[] ID_BYTES = ID.getBytes(Charset.forName("UTF-8"));
@@ -49,8 +48,7 @@
  *       messageDigest.update(ID_BYTES);
  *     }
  * }
- * 
- * 
+ * }
* *

Using the fully qualified class name as a static final {@link String} (not {@link * Class#getName()} to avoid proguard obfuscation) is an easy way to implement {@link diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapTransitionOptions.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapTransitionOptions.java index 1a2a3a7b2b..9ff5381c5d 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapTransitionOptions.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/BitmapTransitionOptions.java @@ -125,4 +125,18 @@ public BitmapTransitionOptions transitionUsing( public BitmapTransitionOptions crossFade(@NonNull DrawableCrossFadeFactory.Builder builder) { return transitionUsing(builder.build()); } + + // Make sure that we're not equal to any other concrete implementation of TransitionOptions. + @Override + public boolean equals(Object o) { + return o instanceof BitmapTransitionOptions && super.equals(o); + } + + // Our class doesn't include any additional properties, so we don't need to modify hashcode, but + // keep it here as a reminder in case we add properties. + @SuppressWarnings("PMD.UselessOverridingMethod") + @Override + public int hashCode() { + return super.hashCode(); + } } diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ByteBufferBitmapImageDecoderResourceDecoder.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ByteBufferBitmapImageDecoderResourceDecoder.java index c9a18528f3..975c76d90a 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ByteBufferBitmapImageDecoderResourceDecoder.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ByteBufferBitmapImageDecoderResourceDecoder.java @@ -4,7 +4,6 @@ import android.graphics.ImageDecoder; import android.graphics.ImageDecoder.Source; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.bumptech.glide.load.Options; import com.bumptech.glide.load.ResourceDecoder; @@ -26,7 +25,6 @@ public boolean handles(@NonNull ByteBuffer source, @NonNull Options options) thr return true; } - @Nullable @Override public Resource decode( @NonNull ByteBuffer buffer, int width, int height, @NonNull Options options) diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/DefaultImageHeaderParser.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/DefaultImageHeaderParser.java index e35b7f962c..66f33758f3 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/DefaultImageHeaderParser.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/DefaultImageHeaderParser.java @@ -1,6 +1,10 @@ package com.bumptech.glide.load.resource.bitmap; +import static com.bumptech.glide.load.ImageHeaderParser.ImageType.ANIMATED_AVIF; +import static com.bumptech.glide.load.ImageHeaderParser.ImageType.ANIMATED_WEBP; +import static com.bumptech.glide.load.ImageHeaderParser.ImageType.AVIF; import static com.bumptech.glide.load.ImageHeaderParser.ImageType.GIF; +import static com.bumptech.glide.load.ImageHeaderParser.ImageType.HEIF; import static com.bumptech.glide.load.ImageHeaderParser.ImageType.JPEG; import static com.bumptech.glide.load.ImageHeaderParser.ImageType.PNG; import static com.bumptech.glide.load.ImageHeaderParser.ImageType.PNG_A; @@ -33,10 +37,14 @@ public final class DefaultImageHeaderParser implements ImageHeaderParser { private static final String JPEG_EXIF_SEGMENT_PREAMBLE = "Exif\0\0"; static final byte[] JPEG_EXIF_SEGMENT_PREAMBLE_BYTES = JPEG_EXIF_SEGMENT_PREAMBLE.getBytes(Charset.forName("UTF-8")); + private static final String JPEG_MPF_SEGMENT_PREAMBLE = "MPF"; + static final byte[] JPEG_MPF_SEGMENT_PREAMBLE_BYTES = + JPEG_MPF_SEGMENT_PREAMBLE.getBytes(Charset.forName("UTF-8")); private static final int SEGMENT_SOS = 0xDA; private static final int MARKER_EOI = 0xD9; static final int SEGMENT_START_ID = 0xFF; static final int EXIF_SEGMENT_TYPE = 0xE1; + static final int APP2_SEGMENT_TYPE = 0xE2; private static final int ORIENTATION_TAG_TYPE = 0x0112; private static final int[] BYTES_PER_FORMAT = {0, 1, 1, 2, 4, 8, 1, 1, 2, 4, 8, 4, 8}; // WebP-related @@ -52,8 +60,23 @@ public final class DefaultImageHeaderParser implements ImageHeaderParser { private static final int VP8_HEADER_TYPE_EXTENDED = 0x00000058; // 'L' private static final int VP8_HEADER_TYPE_LOSSLESS = 0x0000004C; + private static final int WEBP_EXTENDED_ANIMATION_FLAG = 1 << 1; private static final int WEBP_EXTENDED_ALPHA_FLAG = 1 << 4; private static final int WEBP_LOSSLESS_ALPHA_FLAG = 1 << 3; + // Avif-related + // "ftyp" + private static final int FTYP_HEADER = 0x66747970; + // "avif" + private static final int AVIF_BRAND = 0x61766966; + // "avis" + private static final int AVIS_BRAND = 0x61766973; + // HEIF-related + private static final int HEIC_BRAND = 0x68656963; + private static final int HEIX_BRAND = 0x68656978; + private static final int HEVC_BRAND = 0x68657663; + private static final int HEVX_BRAND = 0x68657678; + private static final int MIF1_BRAND = 0x6d696631; + private static final int MSF1_BRAND = 0x6d736631; @NonNull @Override @@ -83,6 +106,49 @@ public int getOrientation(@NonNull ByteBuffer byteBuffer, @NonNull ArrayPool byt Preconditions.checkNotNull(byteArrayPool)); } + @Override + public boolean hasJpegMpf(@NonNull InputStream is, @NonNull ArrayPool byteArrayPool) + throws IOException { + return hasJpegMpf( + new StreamReader(Preconditions.checkNotNull(is)), + Preconditions.checkNotNull(byteArrayPool)); + } + + @Override + public boolean hasJpegMpf(@NonNull ByteBuffer byteBuffer, @NonNull ArrayPool byteArrayPool) + throws IOException { + return hasJpegMpf( + new ByteBufferReader(Preconditions.checkNotNull(byteBuffer)), + Preconditions.checkNotNull(byteArrayPool)); + } + + private boolean hasJpegMpf(@NonNull Reader reader, @NonNull ArrayPool byteArrayPool) + throws IOException { + if (getType(reader) != JPEG) { + return false; + } + int app2SegmentLength = moveToApp2SegmentAndGetLength(reader); + while (app2SegmentLength > 0) { + byte[] app2Data = byteArrayPool.get(app2SegmentLength, byte[].class); + try { + boolean hasJpegMpfPreamble = hasJpegMpfPreamble(reader, app2Data, app2SegmentLength); + if (hasJpegMpfPreamble) { + return true; + } + } finally { + byteArrayPool.put(app2Data); + } + app2SegmentLength = moveToApp2SegmentAndGetLength(reader); + } + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v( + TAG, + "hasMpf: Failed to parse APP2 segment length, or no APP2 segment with MPF metadata not" + + " found"); + } + return false; + } + @NonNull private ImageType getType(Reader reader) throws IOException { try { @@ -116,12 +182,14 @@ private ImageType getType(Reader reader) throws IOException { } } - // WebP (reads up to 21 bytes). - // See https://developers.google.com/speed/webp/docs/riff_container for details. if (firstFourBytes != RIFF_HEADER) { - return UNKNOWN; + // Check for AVIF/HEIF (reads up to 32 bytes). If it is a valid FTYP box, then the + // firstFourBytes will be the box size. + return sniffFtyp(reader, /* boxSize= */ firstFourBytes); } + // WebP (reads up to 21 bytes). + // See https://developers.google.com/speed/webp/docs/riff_container for details. // Bytes 4 - 7 contain length information. Skip these. reader.skip(4); final int thirdFourBytes = (reader.getUInt16() << 16) | reader.getUInt16(); @@ -136,7 +204,13 @@ private ImageType getType(Reader reader) throws IOException { // Skip some more length bytes and check for transparency/alpha flag. reader.skip(4); short flags = reader.getUInt8(); - return (flags & WEBP_EXTENDED_ALPHA_FLAG) != 0 ? ImageType.WEBP_A : ImageType.WEBP; + if ((flags & WEBP_EXTENDED_ANIMATION_FLAG) != 0) { + return ANIMATED_WEBP; + } else if ((flags & WEBP_EXTENDED_ALPHA_FLAG) != 0) { + return ImageType.WEBP_A; + } else { + return ImageType.WEBP; + } } if ((fourthFourBytes & VP8_HEADER_TYPE_MASK) == VP8_HEADER_TYPE_LOSSLESS) { // See chromium.googlesource.com/webm/libwebp/+/master/doc/webp-lossless-bitstream-spec.txt @@ -155,6 +229,64 @@ private ImageType getType(Reader reader) throws IOException { } } + /** + * Check if the bits look like an AVIF Image. AVIF Specification: + * https://aomediacodec.github.io/av1-avif/ + * + * @return AVIF or ANIMATED_AVIF if the first few bytes look like it could be an AVIF Image or an + * animated AVIF Image respectively, UNKNOWN otherwise. + */ + private ImageType sniffFtyp(Reader reader, int boxSize) throws IOException { + int chunkType = (reader.getUInt16() << 16) | reader.getUInt16(); + if (chunkType != FTYP_HEADER) { + return UNKNOWN; + } + // majorBrand. + int brand = (reader.getUInt16() << 16) | reader.getUInt16(); + // The overall logic is that, if any of the brands are 'avis', then we can conclude immediately + // that it is an animated AVIF image. Otherwise, we conclude after seeing all the brands that if + // one of them is 'avif', the it is a still AVIF image. + if (brand == AVIS_BRAND) { + return ANIMATED_AVIF; + } + boolean avifBrandSeen = brand == AVIF_BRAND; + boolean heifBrandSeen = isHeifBrand(brand); + // Skip the minor version. + reader.skip(4); + // Check the first five minor brands. While there could theoretically be more than five minor + // brands, it is rare in practice. This way we stop the loop from running several times on a + // blob that just happened to look like an ftyp box. + int sizeRemaining = boxSize - 16; + if (sizeRemaining % 4 == 0) { + for (int i = 0; i < 5 && sizeRemaining > 0; ++i, sizeRemaining -= 4) { + brand = (reader.getUInt16() << 16) | reader.getUInt16(); + if (brand == AVIS_BRAND) { + return ANIMATED_AVIF; + } else if (brand == AVIF_BRAND) { + avifBrandSeen = true; + } else if (isHeifBrand(brand)) { + heifBrandSeen = true; + } + } + } + if (avifBrandSeen) { + return AVIF; + } + if (heifBrandSeen) { + return HEIF; + } + return UNKNOWN; + } + + private static boolean isHeifBrand(int brand) { + return brand == HEIC_BRAND + || brand == HEIX_BRAND + || brand == HEVC_BRAND + || brand == HEVX_BRAND + || brand == MIF1_BRAND + || brand == MSF1_BRAND; + } + /** * Parse the orientation from the image header. If it doesn't handle this image type (or this is * not an image) it will return a default value rather than throwing an exception. @@ -224,11 +356,14 @@ private int parseExifSegment(Reader reader, byte[] tempArray, int exifSegmentLen } private boolean hasJpegExifPreamble(byte[] exifData, int exifSegmentLength) { - boolean result = - exifData != null && exifSegmentLength > JPEG_EXIF_SEGMENT_PREAMBLE_BYTES.length; + return hasMatchingBytes(exifData, exifSegmentLength, JPEG_EXIF_SEGMENT_PREAMBLE_BYTES); + } + + private boolean hasMatchingBytes(byte[] bytes, int byteLength, byte[] bytesToMatch) { + boolean result = bytes != null && bytesToMatch != null && byteLength > bytesToMatch.length; if (result) { - for (int i = 0; i < JPEG_EXIF_SEGMENT_PREAMBLE_BYTES.length; i++) { - if (exifData[i] != JPEG_EXIF_SEGMENT_PREAMBLE_BYTES[i]) { + for (int i = 0; i < bytesToMatch.length; i++) { + if (bytes[i] != bytesToMatch[i]) { result = false; break; } @@ -242,6 +377,48 @@ private boolean hasJpegExifPreamble(byte[] exifData, int exifSegmentLength) { * {@code -1} if no exif segment is found. */ private int moveToExifSegmentAndGetLength(Reader reader) throws IOException { + return moveToSegmentAndGetLength(reader, EXIF_SEGMENT_TYPE); + } + + /** + * Returns whether the reader, set at the beginning of the APP2 segment past the length bytes, + * contains multi-picture format (MPF) data. + * + * @param reader must be set at the start of an APP2 segment, past the APP2 label and length + * bytes. + * @param tempArray for storing temporary array. Must be at least the size of {@code + * app2SegmentLength}. + * @param app2SegmentLength the length of the APP2 segment. + * @throws IOException if an EOF is reached before anything was read. + */ + private boolean hasJpegMpfPreamble(Reader reader, byte[] tempArray, int app2SegmentLength) + throws IOException { + int read = reader.read(tempArray, app2SegmentLength); + if (read != app2SegmentLength) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d( + TAG, + "Unable to read APP2 segment data" + + ", length: " + + app2SegmentLength + + ", actually read: " + + read); + } + return false; + } + return hasMatchingBytes(tempArray, app2SegmentLength, JPEG_MPF_SEGMENT_PREAMBLE_BYTES); + } + + private int moveToApp2SegmentAndGetLength(Reader reader) throws IOException { + return moveToSegmentAndGetLength(reader, APP2_SEGMENT_TYPE); + } + + /** + * Moves reader to the start of the segment identified by the segment type (e.g., "0xE1" for APP1 + * and returns the length of the exif segment or {@code -1} if no segment of that type is found. + */ + private int moveToSegmentAndGetLength(Reader reader, int requestedSegmentType) + throws IOException { while (true) { short segmentId = reader.getUInt8(); if (segmentId != SEGMENT_START_ID) { @@ -256,7 +433,7 @@ private int moveToExifSegmentAndGetLength(Reader reader) throws IOException { return -1; } else if (segmentType == MARKER_EOI) { if (Log.isLoggable(TAG, Log.DEBUG)) { - Log.d(TAG, "Found MARKER_EOI in exif segment"); + Log.d(TAG, "Found MARKER_EOI in " + requestedSegmentType + " segment"); } return -1; } @@ -264,7 +441,7 @@ private int moveToExifSegmentAndGetLength(Reader reader) throws IOException { int segmentLength = reader.getUInt16(); // A segment includes the bytes that specify its length. int segmentContentsLength = segmentLength - 2; - if (segmentType != EXIF_SEGMENT_TYPE) { + if (segmentType != requestedSegmentType) { long skipped = reader.skip(segmentContentsLength); if (skipped != segmentContentsLength) { if (Log.isLoggable(TAG, Log.DEBUG)) { @@ -521,7 +698,7 @@ public int read(byte[] buffer, int byteCount) throws IOException { int numBytesRead = 0; int lastReadResult = 0; while (numBytesRead < byteCount - && ((lastReadResult = is.read(buffer, numBytesRead, byteCount - numBytesRead)) != -1)) { + && (lastReadResult = is.read(buffer, numBytesRead, byteCount - numBytesRead)) != -1) { numBytesRead += lastReadResult; } diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/DownsampleStrategy.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/DownsampleStrategy.java index b6bcf90d8d..a56f9f0b48 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/DownsampleStrategy.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/DownsampleStrategy.java @@ -83,7 +83,6 @@ public abstract class DownsampleStrategy { /** Performs no downsampling or scaling. */ public static final DownsampleStrategy NONE = new None(); - /** Default strategy, currently {@link #CENTER_OUTSIDE}. */ public static final DownsampleStrategy DEFAULT = CENTER_OUTSIDE; /** diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/Downsampler.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/Downsampler.java index c1ee9e70e6..f2c40ba84a 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/Downsampler.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/Downsampler.java @@ -19,6 +19,7 @@ import com.bumptech.glide.load.Options; import com.bumptech.glide.load.PreferredColorSpace; import com.bumptech.glide.load.data.ParcelFileDescriptorRewinder; +import com.bumptech.glide.load.engine.Engine; import com.bumptech.glide.load.engine.Resource; import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; @@ -64,9 +65,8 @@ public final class Downsampler { * limitations. */ public static final Option PREFERRED_COLOR_SPACE = - Option.memory( - "com.bumptech.glide.load.resource.bitmap.Downsampler.PreferredColorSpace", - PreferredColorSpace.SRGB); + Option.memory("com.bumptech.glide.load.resource.bitmap.Downsampler.PreferredColorSpace"); + /** * Indicates the {@link com.bumptech.glide.load.resource.bitmap.DownsampleStrategy} option that * will be used to calculate the sample size to use to downsample an image given the original and @@ -76,6 +76,7 @@ public final class Downsampler { */ @Deprecated public static final Option DOWNSAMPLE_STRATEGY = DownsampleStrategy.OPTION; + /** * Ensure that the size of the bitmap is fixed to the requested width and height of the resource * from the caller. The final resource dimensions may differ from the requested width and height, @@ -112,6 +113,26 @@ public final class Downsampler { Option.memory( "com.bumptech.glide.load.resource.bitmap.Downsampler.AllowHardwareDecode", false); + /** + * Indicates that we should bypass applying transformations when the decoded bitmap config is + * {@link Bitmap.Config#HARDWARE}. + * + *

Enabling this option avoids copying hardware bitmaps to software canvases for + * transformations (like center-cropping or rounding), which reduces memory usage and avoids + * crashes caused by software rendering of hardware bitmaps. + * + *

Tradeoffs: Enabled transformations will NOT be applied to the resulting bitmap. This is only + * safe for display-only layouts (like grids) where the target {@link android.widget.ImageView} + * can handle the scaling/cropping using its {@link android.widget.ImageView.ScaleType} (e.g., + * {@link android.widget.ImageView.ScaleType#CENTER_CROP}). + * + *

This option is ignored unless {@link #ALLOW_HARDWARE_CONFIG} is also enabled. + */ + public static final Option BYPASS_TRANSFORMATIONS_FOR_HARDWARE_BITMAPS = + Option.memory( + "com.bumptech.glide.load.resource.bitmap.Downsampler.BypassTransformationsForHardwareBitmaps", + false); + private static final String WBMP_MIME_TYPE = "image/vnd.wap.wbmp"; private static final String ICO_MIME_TYPE = "image/x-ico"; private static final Set NO_DOWNSAMPLE_PRE_N_MIME_TYPES = @@ -186,8 +207,10 @@ public Resource decode(InputStream is, int outWidth, int outHeight, Opti public Resource decode( ByteBuffer buffer, int requestedWidth, int requestedHeight, Options options) throws IOException { + boolean enableDirectByteBufferDecoding = false; return decode( - new ImageReader.ByteBufferReader(buffer, parsers, byteArrayPool), + new ImageReader.ByteBufferReader( + buffer, parsers, byteArrayPool, enableDirectByteBufferDecoding), requestedWidth, requestedHeight, options, @@ -415,20 +438,28 @@ private Bitmap decodeFromWrappedStreams( } } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - boolean isP3Eligible = - preferredColorSpace == PreferredColorSpace.DISPLAY_P3 - && options.outColorSpace != null - && options.outColorSpace.isWideGamut(); - options.inPreferredColorSpace = - ColorSpace.get(isP3Eligible ? ColorSpace.Named.DISPLAY_P3 : ColorSpace.Named.SRGB); - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - options.inPreferredColorSpace = ColorSpace.get(ColorSpace.Named.SRGB); + if (preferredColorSpace != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + boolean isP3Eligible = + preferredColorSpace == PreferredColorSpace.DISPLAY_P3 + && options.outColorSpace != null + && options.outColorSpace.isWideGamut(); + options.inPreferredColorSpace = + ColorSpace.get(isP3Eligible ? ColorSpace.Named.DISPLAY_P3 : ColorSpace.Named.SRGB); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + options.inPreferredColorSpace = ColorSpace.get(ColorSpace.Named.SRGB); + } } Bitmap downsampled = decodeStream(imageReader, options, callbacks, bitmapPool); callbacks.onDecodeComplete(bitmapPool, downsampled); + if (downsampled != null && sourceWidth > 0 && sourceHeight > 0) { + if (Log.isLoggable(Engine.GLIDE_MEMORY_TRACKING_TAG, Log.DEBUG)) { + logMemoryTracking(downsampleStrategy, downsampled, sourceWidth, sourceHeight); + } + } + if (Log.isLoggable(TAG, Log.VERBOSE)) { logDecode( sourceWidth, @@ -456,6 +487,20 @@ private Bitmap decodeFromWrappedStreams( return rotated; } + private static void logMemoryTracking( + DownsampleStrategy downsampleStrategy, + Bitmap downsampled, + int sourceWidth, + int sourceHeight) { + Util.logMemoryTracking( + Engine.GLIDE_MEMORY_TRACKING_TAG, + "Downsampler", + downsampleStrategy.getClass().getSimpleName(), + downsampled, + sourceWidth, + sourceHeight); + } + private static void calculateScaling( ImageType imageType, ImageReader imageReader, @@ -575,7 +620,7 @@ private static void calculateScaling( } else if (imageType == ImageType.PNG || imageType == ImageType.PNG_A) { powerOfTwoWidth = (int) Math.floor(orientedSourceWidth / (float) powerOfTwoSampleSize); powerOfTwoHeight = (int) Math.floor(orientedSourceHeight / (float) powerOfTwoSampleSize); - } else if (imageType == ImageType.WEBP || imageType == ImageType.WEBP_A) { + } else if (imageType.isWebp()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { powerOfTwoWidth = Math.round(orientedSourceWidth / (float) powerOfTwoSampleSize); powerOfTwoHeight = Math.round(orientedSourceHeight / (float) powerOfTwoSampleSize); diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/DrawableTransformation.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/DrawableTransformation.java index 601820af3f..eeb98ded2c 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/DrawableTransformation.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/DrawableTransformation.java @@ -20,7 +20,7 @@ * readily accessible. For non-{@link Bitmap} based {@link Drawable}s, this class must first try to * draw the {@link Drawable} to a {@link Bitmap} using {@link android.graphics.Canvas}, which is * less efficient. {@link Drawable}s that implement {@link android.graphics.drawable.Animatable} - * will fail with an exception. {@link Drawable}s that return <= 0 for {@link + * will fail with an exception. {@link Drawable}s that return {@code <= 0} for {@link * Drawable#getIntrinsicHeight()} and/or {@link Drawable#getIntrinsicWidth()} will fail with an * exception if the requested size is {@link * com.bumptech.glide.request.target.Target#SIZE_ORIGINAL}. {@link Drawable}s without intrinsic diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ExifInterfaceImageHeaderParser.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ExifInterfaceImageHeaderParser.java index 6cf65f1f86..a8304cae53 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ExifInterfaceImageHeaderParser.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ExifInterfaceImageHeaderParser.java @@ -52,4 +52,16 @@ public int getOrientation(@NonNull ByteBuffer byteBuffer, @NonNull ArrayPool byt throws IOException { return getOrientation(ByteBufferUtil.toStream(byteBuffer), byteArrayPool); } + + @Override + public boolean hasJpegMpf(@NonNull InputStream is, @NonNull ArrayPool byteArrayPool) + throws IOException { + return false; + } + + @Override + public boolean hasJpegMpf(@NonNull ByteBuffer byteBuffer, @NonNull ArrayPool byteArrayPool) + throws IOException { + return false; + } } diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/GlideBitmapFactory.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/GlideBitmapFactory.java new file mode 100644 index 0000000000..30c8226d85 --- /dev/null +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/GlideBitmapFactory.java @@ -0,0 +1,394 @@ +package com.bumptech.glide.load.resource.bitmap; + +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapFactory; +import android.graphics.BitmapFactory.Options; +import android.graphics.Canvas; +import android.graphics.ColorMatrixColorFilter; +import android.graphics.Gainmap; +import android.graphics.Paint; +import android.graphics.Rect; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.util.Log; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import com.bumptech.glide.util.ByteBufferUtil; +import com.bumptech.glide.util.GlideSuppliers; +import com.bumptech.glide.util.GlideSuppliers.GlideSupplier; +import com.bumptech.glide.util.Preconditions; +import java.io.FileDescriptor; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/** + * Wrapper around {@link BitmapFactory} to work around known issues with {@link BitmapFactory} + * across Android SDK levels. + * + *

In particular, this class works around these known issues: + * + *

    + *
  • Ultra HDR image single-channel gainmaps not being decoded on Android U when hardware + * bitmaps are enabled. This issue is further described in + * https://github.com/bumptech/glide/issues/5362. + *
+ * + *

New usages of {@link BitmapFactory} APIs within Glide should be added here rather than called + * directly. + */ +final class GlideBitmapFactory { + + private static final String TAG = "GlideBitmapFactory"; + + private GlideBitmapFactory() {} + + /** Wrapper for {@link BitmapFactory#decodeStream}. */ + @Nullable + public static Bitmap decodeStream( + InputStream inputStream, BitmapFactory.Options options, ImageReader reader) { + if (VERSION.SDK_INT == VERSION_CODES.UPSIDE_DOWN_CAKE + && GainmapDecoderWorkaroundStateCalculator.needsGainmapDecodeWorkaround(options) + && isLikelyToContainGainmap(reader)) { + return safeAndExpensiveDecodeHardwareBitmapWithGainmap(inputStream, options); + } + return BitmapFactory.decodeStream(inputStream, /* outPadding= */ null, options); + } + + /** Wrapper for decoding a {@link ByteBuffer} directly without an {@link InputStream}. */ + @Nullable + public static Bitmap decodeByteBuffer( + ByteBuffer buffer, BitmapFactory.Options options, ImageReader reader) { + ByteBufferUtil.rewind(buffer); + if (buffer.hasArray() && !buffer.isReadOnly()) { + return decodeByteArray( + buffer.array(), + buffer.arrayOffset() + buffer.position(), + buffer.remaining(), + options, + reader); + } else { + byte[] bytes = ByteBufferUtil.toBytes(buffer); + return decodeByteArray(bytes, /* offset= */ 0, bytes.length, options, reader); + } + } + + /** Wrapper for {@link BitmapFactory#decodeByteArray}. */ + @Nullable + public static Bitmap decodeByteArray( + byte[] bytes, BitmapFactory.Options options, ImageReader reader) { + return decodeByteArray(bytes, /* offset= */ 0, bytes.length, options, reader); + } + + /** Wrapper for {@link BitmapFactory#decodeByteArray} with offset and length. */ + @Nullable + public static Bitmap decodeByteArray( + byte[] bytes, int offset, int length, BitmapFactory.Options options, ImageReader reader) { + if (VERSION.SDK_INT == VERSION_CODES.UPSIDE_DOWN_CAKE + && GainmapDecoderWorkaroundStateCalculator.needsGainmapDecodeWorkaround(options) + && isLikelyToContainGainmap(reader)) { + return safeAndExpensiveDecodeHardwareBitmapWithGainmap(bytes, offset, length, options); + } + return BitmapFactory.decodeByteArray(bytes, offset, length, options); + } + + /** Wrapper for {@link BitmapFactory#decodeFileDescriptor}. */ + @Nullable + public static Bitmap decodeFileDescriptor( + FileDescriptor fileDescriptor, BitmapFactory.Options options, ImageReader reader) { + if (VERSION.SDK_INT == VERSION_CODES.UPSIDE_DOWN_CAKE + && GainmapDecoderWorkaroundStateCalculator.needsGainmapDecodeWorkaround(options) + && isLikelyToContainGainmap(reader)) { + return safeAndExpensiveDecodeHardwareBitmapWithGainmap(fileDescriptor, options); + } + return BitmapFactory.decodeFileDescriptor(fileDescriptor, /* outPadding= */ null, options); + } + + /** + * Returns whether the image referenced by the {@link ImageReader} is likely to have a gainmap. + * + *

On Android devices, a JPEG with multi-picture format (MPF) metadata is very likely to + * contain a gainmap, either it being an Ultra HDR JPEG or a ISO 21496-1 JPEG. + */ + private static boolean isLikelyToContainGainmap(ImageReader imageReader) { + try { + boolean hasMpf = imageReader.hasJpegMpf(); + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, "isLikelyToContainGainmap=" + hasMpf); + } + return hasMpf; + } catch (IOException e) { + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, "isLikelyToContainGainmap failed", e); + } + } + return false; + } + + /** + * Returns a decoded bitmap for the input stream, ensuring that any associated gainmap is decoded + * without being silently dropped on Android U. + * + *

If the input stream does not reference an image with a gainmap, then this method simply + * returns a hardware bitmap. + * + *

This method safely wraps BitmapFactory#decodeStream(InputStream, Rect, Options)} on Android + * U. + * + *

This method performs an expensive workaround, using software bitmap decoding. It is + * recommended to only use this check on images that have a reasonable chance of containing + * gainmaps (e.g., they already contain JPEG multi-picture format metadata). + * + * @param inputStream for the bitmap to be decoded. + * @param options to be applied in the {@link BitmapFactory#decodeStream} call. + */ + @RequiresApi(VERSION_CODES.UPSIDE_DOWN_CAKE) + @Nullable + private static Bitmap safeAndExpensiveDecodeHardwareBitmapWithGainmap( + InputStream inputStream, Options options) { + Preconditions.checkArgument(options.inPreferredConfig == Config.HARDWARE); + Bitmap softwareBitmap = null; + options.inPreferredConfig = Config.ARGB_8888; + try { + softwareBitmap = BitmapFactory.decodeStream(inputStream, /* outPadding= */ null, options); + if (softwareBitmap == null) { + return null; + } + return safeDecodeBitmapWithGainmap(softwareBitmap); + } finally { + if (softwareBitmap != null) { + softwareBitmap.recycle(); + } + options.inPreferredConfig = Config.HARDWARE; + } + } + + /** + * Returns a decoded bitmap for the input byte array, ensuring that any associated gainmap is + * decoded without being silently dropped on Android U. + * + *

If the input bytes do not reference an image with a gainmap, then this method simply returns + * a hardware bitmap. + * + *

This method safely wraps BitmapFactory#decodeByteArray(byte[], int, int)} on Android U. + * + * @param bytes for the bitmap to be decoded. + * @param options to be applied in the {@link BitmapFactory#decodeByteArray} call. This must be + * set to {@link Config#HARDWARE}. + * @throws IllegalArgumentException if {@link Options#inPreferredConfig} is set to any state other + * than {@link Config#HARDWARE}. + */ + @RequiresApi(VERSION_CODES.UPSIDE_DOWN_CAKE) + @Nullable + private static Bitmap safeAndExpensiveDecodeHardwareBitmapWithGainmap( + byte[] bytes, int offset, int length, Options options) { + Preconditions.checkArgument(options.inPreferredConfig == Config.HARDWARE); + options.inPreferredConfig = Bitmap.Config.ARGB_8888; + Bitmap softwareBitmap = null; + try { + softwareBitmap = BitmapFactory.decodeByteArray(bytes, offset, length, options); + if (softwareBitmap == null) { + return null; + } + return GlideBitmapFactory.safeDecodeBitmapWithGainmap(softwareBitmap); + } finally { + if (softwareBitmap != null) { + softwareBitmap.recycle(); + } + options.inPreferredConfig = Config.HARDWARE; + } + } + + /** + * Returns a decoded bitmap for the input file descriptor, ensuring that any associated gainmap is + * decoded without being silently dropped on Android U. + * + *

If the input file descriptor does not reference an image with a gainmap, then this method + * simply returns a hardware bitmap. + * + *

This method safely wraps {@link BitmapFactory#decodeFileDescriptor(FileDescriptor, Rect, + * Options)} on Android U. + * + * @param fileDescriptor from which the bitmap will be decoded. + * @param options to be applied in the {@link BitmapFactory#decodeFileDescriptor} call. This must + * be set to {@link Config#HARDWARE}. + * @throws IllegalArgumentException if {@link Options#inPreferredConfig} is set to any state other + * than {@link Config#HARDWARE}. + */ + @RequiresApi(VERSION_CODES.UPSIDE_DOWN_CAKE) + @Nullable + private static Bitmap safeAndExpensiveDecodeHardwareBitmapWithGainmap( + FileDescriptor fileDescriptor, Options options) { + Preconditions.checkArgument(options.inPreferredConfig == Config.HARDWARE); + Bitmap softwareBitmap = null; + options.inPreferredConfig = Bitmap.Config.ARGB_8888; + try { + softwareBitmap = + BitmapFactory.decodeFileDescriptor(fileDescriptor, /* outPadding= */ null, options); + if (softwareBitmap == null) { + return null; + } + return GlideBitmapFactory.safeDecodeBitmapWithGainmap(softwareBitmap); + } finally { + if (softwareBitmap != null) { + softwareBitmap.recycle(); + } + options.inPreferredConfig = Config.HARDWARE; + } + } + + /** + * Returns a decoded bitmap for the input software bitmap, ensuring that any associated gainmap is + * decoded without errors on Android U if it is a valid gainmap. + * + * @param softwareBitmap The bitmap to be decoded. Must not be a hardware bitmap. The caller of + * this method is responsible for recycling this bitmap. + * @throws IllegalArgumentException if {@link Options#inPreferredConfig} is set to any state other + * than {@link Config#HARDWARE}. + */ + @RequiresApi(VERSION_CODES.UPSIDE_DOWN_CAKE) + @Nullable + private static Bitmap safeDecodeBitmapWithGainmap(Bitmap softwareBitmap) { + Gainmap gainmap = softwareBitmap.getGainmap(); + if (gainmap != null) { + Bitmap gainmapContents = gainmap.getGainmapContents(); + if (gainmapContents.getConfig() == Config.ALPHA_8) { + softwareBitmap.setGainmap( + GainmapCopier.convertSingleChannelGainmapToTripleChannelGainmap(gainmap)); + } + } + return softwareBitmap.copy(Config.HARDWARE, /* isMutable= */ false); + } + + /** Utils to copy gainmaps. */ + @RequiresApi(VERSION_CODES.UPSIDE_DOWN_CAKE) + private static final class GainmapCopier { + + /** Transforms a bitmap so that the output alpha is opaque. */ + private static final ColorMatrixColorFilter OPAQUE_FILTER = + new ColorMatrixColorFilter( + new float[] { + 0f, 0f, 0f, 1f, 0f, + 0f, 0f, 0f, 1f, 0f, + 0f, 0f, 0f, 1f, 0f, + 0f, 0f, 0f, 0f, 255f + }); + + private GainmapCopier() {} + + /** + * Converts single channel gainmap to triple channel, where a single channel gainmap is defined + * as a gainmap with a bitmap config of {@link Config#ALPHA_8}. + * + *

If the input gainmap is not single channel or the copy operation fails, then this method + * will just return the original gainmap. + */ + public static Gainmap convertSingleChannelGainmapToTripleChannelGainmap(Gainmap gainmap) { + Bitmap gainmapContents = gainmap.getGainmapContents(); + if (gainmapContents.getConfig() != Config.ALPHA_8) { + return gainmap; + } + Bitmap newContents = copyAlpha8ToOpaqueArgb888(gainmapContents); + Gainmap newGainmap = new Gainmap(newContents); + float[] tempFloatArray = gainmap.getRatioMin(); + newGainmap.setRatioMin(tempFloatArray[0], tempFloatArray[1], tempFloatArray[2]); + tempFloatArray = gainmap.getRatioMax(); + newGainmap.setRatioMax(tempFloatArray[0], tempFloatArray[1], tempFloatArray[2]); + tempFloatArray = gainmap.getGamma(); + newGainmap.setGamma(tempFloatArray[0], tempFloatArray[1], tempFloatArray[2]); + tempFloatArray = gainmap.getEpsilonSdr(); + newGainmap.setEpsilonSdr(tempFloatArray[0], tempFloatArray[1], tempFloatArray[2]); + tempFloatArray = gainmap.getEpsilonHdr(); + newGainmap.setEpsilonHdr(tempFloatArray[0], tempFloatArray[1], tempFloatArray[2]); + newGainmap.setDisplayRatioForFullHdr(gainmap.getDisplayRatioForFullHdr()); + newGainmap.setMinDisplayRatioForHdrTransition(gainmap.getMinDisplayRatioForHdrTransition()); + return newGainmap; + } + + /** + * Converts an {@link Config#ALPHA_8} bitmap to a {@link Config#ARGB_8888} bitmap with the alpha + * channel set to unity so that the output bitmap is opaque. + * + * @throws IllegalArgumentException if called with a bitmap with a config that is not {@link + * Config#ALPHA_8} + */ + private static Bitmap copyAlpha8ToOpaqueArgb888(Bitmap bitmap) { + Preconditions.checkArgument(bitmap.getConfig() == Config.ALPHA_8); + // We have to use a canvas operation with an opaque alpha filter to draw the gainmap. We can't + // use bitmap.copy(Config.ARGB_8888, /* isMutable= */ false) because copying from A8 to RBGA + // will result in zero-valued RGB values. + Bitmap newContents = + Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Config.ARGB_8888); + Canvas canvas = new Canvas(newContents); + Paint paint = new Paint(); + paint.setColorFilter(OPAQUE_FILTER); + canvas.drawBitmap(bitmap, /* left= */ 0f, /* top= */ 0f, paint); + canvas.setBitmap(null); + return newContents; + } + } + + /** + * Determines if a gainmap decoding workaround is required to mitigate an Android U bug with + * decoding bitmaps with gainmaps. When the following conditions are present, Android U will not + * be able to decode a gainmap, with hardware bitmap operation failing for the gainmap: + * + *

    + *
  • The HWUI is configured to use skiagl. + *
  • The gainmap is single channel. + *
  • The bitmap owning the bitmap is a hardware bitmap. + *
+ * + *

Callers should use this class to determine whether to apply a workaround, e.g., modifying + * the gainmap to be triple channel and software decode it. + */ + public static final class GainmapDecoderWorkaroundStateCalculator { + private static final String TAG = "GainmapWorkaroundCalc"; + + /** Meomizes result of test to see if the device is susceptible to the gainmap decoding bug. */ + private static final GlideSupplier REQUIRES_GAIN_MAP_FIX = + GlideSuppliers.memorize(() -> calculateNeedsGainmapDecodeWorkaround()); + + private GainmapDecoderWorkaroundStateCalculator() {} + + /** + * Returns true if a gainmap decoding workaround is required to mitigate an Android U bug. This + * method tests for the presence of the bug, which only affects hardware bitmaps, and caches the + * result in memory. + * + *

This method is thread-safe. + * + * @param options which will be used to decode the gainmap. + */ + private static boolean needsGainmapDecodeWorkaround(Options options) { + if (VERSION.SDK_INT != VERSION_CODES.UPSIDE_DOWN_CAKE) { + return false; + } + if (options.inPreferredConfig != Config.HARDWARE) { + return false; + } + return REQUIRES_GAIN_MAP_FIX.get(); + } + + private static boolean calculateNeedsGainmapDecodeWorkaround() { + if (VERSION.SDK_INT != VERSION_CODES.UPSIDE_DOWN_CAKE) { + return false; + } + // Create a 1x1 single channel, A8 bitmap and attempt to copy to a hardware bitmap. If the + // copy operation fails, then the device requires a workaround to decode hardware + // gainmaps. + Bitmap a8Source = Bitmap.createBitmap(/* width= */ 1, /* height= */ 1, Config.ALPHA_8); + Bitmap a8HardwareBitmap = a8Source.copy(Config.HARDWARE, /* isMutable= */ false); + a8Source.recycle(); + boolean needsGainmapDecodeWorkaround = a8HardwareBitmap == null; + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, "calculateNeedsGainmapDecodeWorkaround=" + needsGainmapDecodeWorkaround); + } + if (a8HardwareBitmap != null) { + a8HardwareBitmap.recycle(); + } + return needsGainmapDecodeWorkaround; + } + } +} diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/HardwareConfigState.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/HardwareConfigState.java index eaf809b9b9..c1504db9b7 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/HardwareConfigState.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/HardwareConfigState.java @@ -4,7 +4,9 @@ import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.Build; +import android.os.Build.VERSION_CODES; import android.util.Log; +import androidx.annotation.ChecksSdkIntAtLeast; import androidx.annotation.GuardedBy; import androidx.annotation.VisibleForTesting; import com.bumptech.glide.util.Util; @@ -29,21 +31,9 @@ public final class HardwareConfigState { Build.VERSION.SDK_INT < Build.VERSION_CODES.Q; /** Support for the hardware bitmap config was added in Android O. */ + @ChecksSdkIntAtLeast(api = VERSION_CODES.P) public static final boolean HARDWARE_BITMAPS_SUPPORTED = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; - - /** - * The minimum size in pixels a {@link Bitmap} must be in both dimensions to be created with the - * {@link Bitmap.Config#HARDWARE} configuration. - * - *

This is a quick check that lets us skip wasting FDs (see {@link #FD_SIZE_LIST}) on small - * {@link Bitmap}s with relatively low memory costs. - * - * @see #FD_SIZE_LIST - */ - @VisibleForTesting static final int MIN_HARDWARE_DIMENSION_O = 128; - - private static final int MIN_HARDWARE_DIMENSION_P = 0; + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P; /** * Allows us to check to make sure we're not exceeding the FD limit for a process with hardware @@ -66,28 +56,23 @@ public final class HardwareConfigState { */ private static final int MINIMUM_DECODES_BETWEEN_FD_CHECKS = 50; - /** - * 700 with an error of 50 Bitmaps in between at two FDs each lets us use up to 800 FDs for - * hardware Bitmaps. - * - *

Prior to P, the limit per process was 1024 FDs. In P, the limit was updated to 32k FDs per - * process. - * - *

Access to this variable will be removed in a future version without deprecation. - */ - private static final int MAXIMUM_FDS_FOR_HARDWARE_CONFIGS_O = 700; // 20k. private static final int MAXIMUM_FDS_FOR_HARDWARE_CONFIGS_P = 20000; - /** This constant will be removed in a future version without deprecation, avoid using it. */ - public static final int NO_MAX_FD_COUNT = -1; + /** + * Some P devices seem to have a more O like FD count, so we'll manually reduce the number of FDs + * we use for hardware bitmaps. See b/139097735. + */ + private static final int REDUCED_MAX_FDS_FOR_HARDWARE_CONFIGS_P = 500; + + /** + * @deprecated This constant is unused and will be removed in a future version, avoid using it. + */ + @Deprecated public static final int NO_MAX_FD_COUNT = -1; private static volatile HardwareConfigState instance; - private static volatile int manualOverrideMaxFdCount = NO_MAX_FD_COUNT; - private final boolean isHardwareConfigAllowedByDeviceModel; private final int sdkBasedMaxFdCount; - private final int minHardwareDimension; @GuardedBy("this") private int decodesSinceLastFdCheck; @@ -116,19 +101,7 @@ public static HardwareConfigState getInstance() { @VisibleForTesting HardwareConfigState() { - isHardwareConfigAllowedByDeviceModel = isHardwareConfigAllowedByDeviceModel(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - sdkBasedMaxFdCount = MAXIMUM_FDS_FOR_HARDWARE_CONFIGS_P; - minHardwareDimension = MIN_HARDWARE_DIMENSION_P; - } else { - sdkBasedMaxFdCount = MAXIMUM_FDS_FOR_HARDWARE_CONFIGS_O; - minHardwareDimension = MIN_HARDWARE_DIMENSION_O; - } - } - - public boolean areHardwareBitmapsBlocked() { - Util.assertMainThread(); - return !isHardwareConfigAllowedByAppState.get(); + sdkBasedMaxFdCount = MAXIMUM_FDS_FOR_HARDWARE_CONFIGS_P; } public void blockHardwareBitmaps() { @@ -152,12 +125,6 @@ public boolean isHardwareConfigAllowed( } return false; } - if (!isHardwareConfigAllowedByDeviceModel) { - if (Log.isLoggable(TAG, Log.VERBOSE)) { - Log.v(TAG, "Hardware config disallowed by device model"); - } - return false; - } if (!HARDWARE_BITMAPS_SUPPORTED) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Hardware config disallowed by sdk"); @@ -176,15 +143,9 @@ public boolean isHardwareConfigAllowed( } return false; } - if (targetWidth < minHardwareDimension) { + if (targetWidth < 0 || targetHeight < 0) { if (Log.isLoggable(TAG, Log.VERBOSE)) { - Log.v(TAG, "Hardware config disallowed because width is too small"); - } - return false; - } - if (targetHeight < minHardwareDimension) { - if (Log.isLoggable(TAG, Log.VERBOSE)) { - Log.v(TAG, "Hardware config disallowed because height is too small"); + Log.v(TAG, "Hardware config disallowed because of invalid dimensions"); } return false; } @@ -221,68 +182,26 @@ boolean setHardwareConfigIfAllowed( return result; } - private static boolean isHardwareConfigAllowedByDeviceModel() { - return !isHardwareConfigDisallowedByB112551574() && !isHardwareConfigDisallowedByB147430447(); - } - - private static boolean isHardwareConfigDisallowedByB147430447() { - if (Build.VERSION.SDK_INT != Build.VERSION_CODES.O_MR1) { - return false; - } - // This method will only be called once, so simple iteration is reasonable. - return Arrays.asList( - "LG-M250", - "LG-M320", - "LG-Q710AL", - "LG-Q710PL", - "LGM-K121K", - "LGM-K121L", - "LGM-K121S", - "LGM-X320K", - "LGM-X320L", - "LGM-X320S", - "LGM-X401L", - "LGM-X401S", - "LM-Q610.FG", - "LM-Q610.FGN", - "LM-Q617.FG", - "LM-Q617.FGN", - "LM-Q710.FG", - "LM-Q710.FGN", - "LM-X220PM", - "LM-X220QMA", - "LM-X410PM") - .contains(Build.MODEL); - } - - private static boolean isHardwareConfigDisallowedByB112551574() { - if (Build.VERSION.SDK_INT != Build.VERSION_CODES.O) { + private static boolean isHardwareBitmapCountReducedOnApi28ByB139097735() { + if (Build.VERSION.SDK_INT != Build.VERSION_CODES.P) { return false; } - // This method will only be called once, so simple iteration is reasonable. for (String prefixOrModelName : - // This is sadly a list of prefixes, not models. We no longer have the data that shows us - // all the explicit models, so we have to live with the prefixes. Arrays.asList( - // Samsung - "SC-04J", - "SM-N935", - "SM-J720", - "SM-G570F", - "SM-G570M", - "SM-G960", - "SM-G965", - "SM-G935", - "SM-G930", - "SM-A520", - "SM-A720F", - // Moto - "moto e5", - "moto e5 play", - "moto e5 plus", - "moto e5 cruise", - "moto g(6) forge", - "moto g(6) play")) { + "GM1900", + "GM1901", + "GM1903", + "GM1911", + "GM1915", + "ONEPLUS A3000", + "ONEPLUS A3010", + "ONEPLUS A5010", + "ONEPLUS A5000", + "ONEPLUS A3003", + "ONEPLUS A6000", + "ONEPLUS A6003", + "ONEPLUS A6010", + "ONEPLUS A6013")) { if (Build.MODEL.startsWith(prefixOrModelName)) { return true; } @@ -291,9 +210,10 @@ private static boolean isHardwareConfigDisallowedByB112551574() { } private int getMaxFdCount() { - return manualOverrideMaxFdCount != NO_MAX_FD_COUNT - ? manualOverrideMaxFdCount - : sdkBasedMaxFdCount; + if (isHardwareBitmapCountReducedOnApi28ByB139097735()) { + return REDUCED_MAX_FDS_FOR_HARDWARE_CONFIGS_P; + } + return sdkBasedMaxFdCount; } private synchronized boolean isFdSizeBelowHardwareLimit() { diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ImageReader.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ImageReader.java index 240279e9f2..d1a74cc6bb 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ImageReader.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ImageReader.java @@ -3,10 +3,8 @@ import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.BitmapFactory.Options; -import android.os.Build; import android.os.ParcelFileDescriptor; import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; import com.bumptech.glide.load.ImageHeaderParser; import com.bumptech.glide.load.ImageHeaderParser.ImageType; import com.bumptech.glide.load.ImageHeaderParserUtils; @@ -17,6 +15,7 @@ import com.bumptech.glide.util.ByteBufferUtil; import com.bumptech.glide.util.Preconditions; import java.io.File; +import java.io.FileDescriptor; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; @@ -29,6 +28,7 @@ * type wrapped into a {@link DataRewinder}. */ interface ImageReader { + @Nullable Bitmap decodeBitmap(BitmapFactory.Options options) throws IOException; @@ -36,6 +36,8 @@ interface ImageReader { int getImageOrientation() throws IOException; + boolean hasJpegMpf() throws IOException; + void stopGrowingBuffers(); final class ByteArrayReader implements ImageReader { @@ -53,7 +55,7 @@ final class ByteArrayReader implements ImageReader { @Nullable @Override public Bitmap decodeBitmap(Options options) { - return BitmapFactory.decodeByteArray(bytes, /* offset= */ 0, bytes.length, options); + return GlideBitmapFactory.decodeByteArray(bytes, options, this); } @Override @@ -66,6 +68,11 @@ public int getImageOrientation() throws IOException { return ImageHeaderParserUtils.getOrientation(parsers, ByteBuffer.wrap(bytes), byteArrayPool); } + @Override + public boolean hasJpegMpf() throws IOException { + return ImageHeaderParserUtils.hasJpegMpf(parsers, ByteBuffer.wrap(bytes), byteArrayPool); + } + @Override public void stopGrowingBuffers() {} } @@ -88,7 +95,7 @@ public Bitmap decodeBitmap(Options options) throws FileNotFoundException { InputStream is = null; try { is = new RecyclableBufferedInputStream(new FileInputStream(file), byteArrayPool); - return BitmapFactory.decodeStream(is, /* outPadding= */ null, options); + return GlideBitmapFactory.decodeStream(is, options, this); } finally { if (is != null) { try { @@ -134,6 +141,23 @@ public int getImageOrientation() throws IOException { } } + @Override + public boolean hasJpegMpf() throws IOException { + InputStream is = null; + try { + is = new FileInputStream(file); + return ImageHeaderParserUtils.hasJpegMpf(parsers, is, byteArrayPool); + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + // Ignored. + } + } + } + } + @Override public void stopGrowingBuffers() {} } @@ -143,17 +167,32 @@ final class ByteBufferReader implements ImageReader { private final ByteBuffer buffer; private final List parsers; private final ArrayPool byteArrayPool; + private final boolean enableDirectByteBufferDecoding; ByteBufferReader(ByteBuffer buffer, List parsers, ArrayPool byteArrayPool) { + this(buffer, parsers, byteArrayPool, /* enableDirectByteBufferDecoding= */ true); + } + + ByteBufferReader( + ByteBuffer buffer, + List parsers, + ArrayPool byteArrayPool, + boolean enableDirectByteBufferDecoding) { this.buffer = buffer; this.parsers = parsers; this.byteArrayPool = byteArrayPool; + this.enableDirectByteBufferDecoding = enableDirectByteBufferDecoding; } @Nullable @Override public Bitmap decodeBitmap(Options options) { - return BitmapFactory.decodeStream(stream(), /* outPadding= */ null, options); + if (enableDirectByteBufferDecoding) { + return GlideBitmapFactory.decodeByteBuffer(buffer, options, this); + } else { + InputStream inputStream = stream(); + return GlideBitmapFactory.decodeStream(inputStream, options, this); + } } @Override @@ -167,6 +206,12 @@ public int getImageOrientation() throws IOException { parsers, ByteBufferUtil.rewind(buffer), byteArrayPool); } + @Override + public boolean hasJpegMpf() throws IOException { + return ImageHeaderParserUtils.hasJpegMpf( + parsers, ByteBufferUtil.rewind(buffer), byteArrayPool); + } + @Override public void stopGrowingBuffers() {} @@ -191,7 +236,8 @@ final class InputStreamImageReader implements ImageReader { @Nullable @Override public Bitmap decodeBitmap(BitmapFactory.Options options) throws IOException { - return BitmapFactory.decodeStream(dataRewinder.rewindAndGet(), null, options); + InputStream inputStream = dataRewinder.rewindAndGet(); + return GlideBitmapFactory.decodeStream(inputStream, options, this); } @Override @@ -205,13 +251,17 @@ public int getImageOrientation() throws IOException { parsers, dataRewinder.rewindAndGet(), byteArrayPool); } + @Override + public boolean hasJpegMpf() throws IOException { + return ImageHeaderParserUtils.hasJpegMpf(parsers, dataRewinder.rewindAndGet(), byteArrayPool); + } + @Override public void stopGrowingBuffers() { dataRewinder.fixMarkLimits(); } } - @RequiresApi(Build.VERSION_CODES.LOLLIPOP) final class ParcelFileDescriptorImageReader implements ImageReader { private final ArrayPool byteArrayPool; private final List parsers; @@ -230,8 +280,9 @@ final class ParcelFileDescriptorImageReader implements ImageReader { @Nullable @Override public Bitmap decodeBitmap(BitmapFactory.Options options) throws IOException { - return BitmapFactory.decodeFileDescriptor( - dataRewinder.rewindAndGet().getFileDescriptor(), null, options); + ParcelFileDescriptor parcelFileDescriptor = dataRewinder.rewindAndGet(); + FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor(); + return GlideBitmapFactory.decodeFileDescriptor(fileDescriptor, options, this); } @Override @@ -244,6 +295,11 @@ public int getImageOrientation() throws IOException { return ImageHeaderParserUtils.getOrientation(parsers, dataRewinder, byteArrayPool); } + @Override + public boolean hasJpegMpf() throws IOException { + return ImageHeaderParserUtils.hasJpegMpf(parsers, dataRewinder, byteArrayPool); + } + @Override public void stopGrowingBuffers() { // Nothing to do here. diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/InputStreamBitmapImageDecoderResourceDecoder.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/InputStreamBitmapImageDecoderResourceDecoder.java index 0c0c8c0e9a..ee595a85d3 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/InputStreamBitmapImageDecoderResourceDecoder.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/InputStreamBitmapImageDecoderResourceDecoder.java @@ -6,31 +6,60 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import com.bumptech.glide.load.ImageHeaderParser; +import com.bumptech.glide.load.ImageHeaderParser.ImageType; +import com.bumptech.glide.load.ImageHeaderParserUtils; import com.bumptech.glide.load.Options; import com.bumptech.glide.load.ResourceDecoder; import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; import com.bumptech.glide.util.ByteBufferUtil; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; +import java.util.List; /** {@link InputStream} specific implementation of {@link BitmapImageDecoderResourceDecoder}. */ @RequiresApi(api = 28) public final class InputStreamBitmapImageDecoderResourceDecoder implements ResourceDecoder { private final BitmapImageDecoderResourceDecoder wrapped = new BitmapImageDecoderResourceDecoder(); + private final List parsers; + private final boolean useHeapBuffer; + @Nullable private final ArrayPool arrayPool; + private final boolean useArrayPool; + + public InputStreamBitmapImageDecoderResourceDecoder( + List parsers, + boolean useHeapBuffer, + @Nullable ArrayPool arrayPool, + boolean useArrayPool) { + this.parsers = parsers; + this.useHeapBuffer = useHeapBuffer; + this.arrayPool = arrayPool; + this.useArrayPool = useArrayPool; + } @Override public boolean handles(@NonNull InputStream source, @NonNull Options options) throws IOException { - return true; + if (!useArrayPool) { + return true; + } + if (arrayPool == null) { + return false; + } + ImageType type = ImageHeaderParserUtils.getType(parsers, source, arrayPool); + return type != ImageType.UNKNOWN; } - @Nullable @Override public Resource decode( @NonNull InputStream stream, int width, int height, @NonNull Options options) throws IOException { - ByteBuffer buffer = ByteBufferUtil.fromStream(stream); + ByteBuffer buffer = + useArrayPool && arrayPool != null + ? ByteBufferUtil.fromStream(stream, useHeapBuffer, arrayPool) + : ByteBufferUtil.fromStream(stream, useHeapBuffer); Source source = ImageDecoder.createSource(buffer); return wrapped.decode(source, width, height, options); } diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ParcelFileDescriptorBitmapDecoder.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ParcelFileDescriptorBitmapDecoder.java index b92fa1983d..c155e9bd24 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ParcelFileDescriptorBitmapDecoder.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ParcelFileDescriptorBitmapDecoder.java @@ -16,6 +16,12 @@ public final class ParcelFileDescriptorBitmapDecoder implements ResourceDecoder { + // 512MB. While I don't have data on the number of valid image files > 512mb, I have determined + // that virtually all crashes related to Huawei/Honor's DRM checker go away when we don't attempt + // to decode files larger than this. We could increase this to 1GB safely, but it seems like 512MB + // might be a little better from a crash reduction perspective. See b/201464175. + private static final int MAXIMUM_FILE_BYTE_SIZE_FOR_FILE_DESCRIPTOR_DECODER = 512 * 1024 * 1024; + private final Downsampler downsampler; public ParcelFileDescriptorBitmapDecoder(Downsampler downsampler) { @@ -24,7 +30,15 @@ public ParcelFileDescriptorBitmapDecoder(Downsampler downsampler) { @Override public boolean handles(@NonNull ParcelFileDescriptor source, @NonNull Options options) { - return downsampler.handles(source); + return isSafeToTryDecoding(source) && downsampler.handles(source); + } + + private boolean isSafeToTryDecoding(@NonNull ParcelFileDescriptor source) { + if ("HUAWEI".equalsIgnoreCase(Build.MANUFACTURER) + || "HONOR".equalsIgnoreCase(Build.MANUFACTURER)) { + return source.getStatSize() <= MAXIMUM_FILE_BYTE_SIZE_FOR_FILE_DESCRIPTOR_DECODER; + } + return true; } @Nullable diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ResourceBitmapDecoder.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ResourceBitmapDecoder.java index 1d3b376490..36b3ce493c 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ResourceBitmapDecoder.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/ResourceBitmapDecoder.java @@ -20,11 +20,11 @@ *

The framework will decode some resources as {@link Drawable}s that do not wrap {@link * Bitmap}s. This decoder will attempt to return a {@link Bitmap} for those {@link Drawable}s anyway * by drawing the {@link Drawable} to a {@link Canvas}s using the {@link Drawable}'s intrinsic - * bounds or the dimensions provided to {@link #decode(Object, int, int, Options)}. + * bounds or the dimensions provided to {@link #decode(Uri, int, int, Options)}. * - *

For non-{@link Bitmap} {@link Drawable}s that return <= 0 for {@link + *

For non-{@link Bitmap} {@link Drawable}s that return {@code <= 0} for {@link * Drawable#getIntrinsicWidth()} and/or {@link Drawable#getIntrinsicHeight()}, this decoder will - * fail if the width and height provided to {@link #decode(Object, int, int, Options)} are {@link + * fail if the width and height provided to {@link #decode(Uri, int, int, Options)} are {@link * Target#SIZE_ORIGINAL}. */ public class ResourceBitmapDecoder implements ResourceDecoder { diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/TransformationUtils.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/TransformationUtils.java index c663713917..e22a7f996e 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/TransformationUtils.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/TransformationUtils.java @@ -12,15 +12,16 @@ import android.graphics.PorterDuffXfermode; import android.graphics.RectF; import android.graphics.Shader; -import android.media.ExifInterface; import android.os.Build; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; -import com.bumptech.glide.load.Transformation; +import androidx.exifinterface.media.ExifInterface; +import com.bumptech.glide.load.engine.Engine; import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; import com.bumptech.glide.util.Preconditions; import com.bumptech.glide.util.Synthetic; +import com.bumptech.glide.util.Util; import java.util.Arrays; import java.util.HashSet; import java.util.Set; @@ -137,6 +138,22 @@ public static Bitmap centerCrop( TransformationUtils.setAlpha(inBitmap, result); applyMatrix(inBitmap, result, m); + + if (result != null && !result.equals(inBitmap)) { + // Log if centerCrop scaled the bitmap (either up or down). + if (result.getWidth() != inBitmap.getWidth() || result.getHeight() != inBitmap.getHeight()) { + if (Log.isLoggable(Engine.GLIDE_MEMORY_TRACKING_TAG, Log.DEBUG)) { + Util.logMemoryTracking( + Engine.GLIDE_MEMORY_TRACKING_TAG, + "TransformationUtils [centerCrop]", + null, + result, + inBitmap.getWidth(), + inBitmap.getHeight()); + } + } + } + return result; } @@ -198,6 +215,22 @@ public static Bitmap fitCenter( matrix.setScale(minPercentage, minPercentage); applyMatrix(inBitmap, toReuse, matrix); + if (toReuse != null && !toReuse.equals(inBitmap)) { + // Log if fitCenter scaled the bitmap (either up or down). + if (toReuse.getWidth() != inBitmap.getWidth() + || toReuse.getHeight() != inBitmap.getHeight()) { + if (Log.isLoggable(Engine.GLIDE_MEMORY_TRACKING_TAG, Log.DEBUG)) { + Util.logMemoryTracking( + Engine.GLIDE_MEMORY_TRACKING_TAG, + "TransformationUtils [fitCenter]", + null, + toReuse, + inBitmap.getWidth(), + inBitmap.getHeight()); + } + } + } + return toReuse; } @@ -318,22 +351,16 @@ public static Bitmap rotateImageExif( final Matrix matrix = new Matrix(); initializeMatrixForRotation(exifOrientation, matrix); - // From Bitmap.createBitmap. - final RectF newRect = new RectF(0, 0, inBitmap.getWidth(), inBitmap.getHeight()); - matrix.mapRect(newRect); - - final int newWidth = Math.round(newRect.width()); - final int newHeight = Math.round(newRect.height()); - - Bitmap.Config config = getNonNullConfig(inBitmap); - Bitmap result = pool.get(newWidth, newHeight, config); - - matrix.postTranslate(-newRect.left, -newRect.top); - - result.setHasAlpha(inBitmap.hasAlpha()); - - applyMatrix(inBitmap, result, matrix); - return result; + // BitmapPool doesn't preserve gainmaps and color space, so use Bitmap.create to apply the + // matrix. + return Bitmap.createBitmap( + inBitmap, + /* x= */ 0, + /* y= */ 0, + inBitmap.getWidth(), + inBitmap.getHeight(), + matrix, + /* filter= */ true); } /** diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/UriBitmapImageDecoderResourceDecoder.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/UriBitmapImageDecoderResourceDecoder.java new file mode 100644 index 0000000000..ed6ca48047 --- /dev/null +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/UriBitmapImageDecoderResourceDecoder.java @@ -0,0 +1,81 @@ +package com.bumptech.glide.load.resource.bitmap; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.ImageDecoder; +import android.graphics.ImageDecoder.Source; +import android.net.Uri; +import android.os.Build; +import android.util.Log; +import android.webkit.MimeTypeMap; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.engine.Resource; +import java.io.IOException; +import java.util.Locale; + +/** Decodes {@link Bitmap}s from {@link Uri}s using {@link ImageDecoder}. */ +@RequiresApi(Build.VERSION_CODES.P) +public final class UriBitmapImageDecoderResourceDecoder implements ResourceDecoder { + private static final String TAG = "UriBitmapDecoder"; + private final Context context; + private final BitmapImageDecoderResourceDecoder wrapped = new BitmapImageDecoderResourceDecoder(); + + public UriBitmapImageDecoderResourceDecoder(@NonNull Context context) { + this.context = context.getApplicationContext(); + } + + @Override + public boolean handles(@NonNull Uri uri, @NonNull Options options) throws IOException { + String scheme = uri.getScheme(); + boolean isSupportedScheme = + ContentResolver.SCHEME_CONTENT.equals(scheme) + || ContentResolver.SCHEME_FILE.equals(scheme) + || ContentResolver.SCHEME_ANDROID_RESOURCE.equals(scheme); + if (!isSupportedScheme) { + return false; + } + String mimeType = getMimeType(uri); + if (mimeType == null) { + // ContentResolver.getType() can return null for resources in tests (Robolectric) or for some + // raw resources. We want to be lenient and handle them as they are internal to the app, + // but we reject null MIME types for content/file URIs to be safe. + return ContentResolver.SCHEME_ANDROID_RESOURCE.equals(scheme); + } + return mimeType.startsWith("image/") && !mimeType.equals("image/gif"); + } + + @Nullable + private String getMimeType(@NonNull Uri uri) { + String mimeType = context.getContentResolver().getType(uri); + if (mimeType == null && ContentResolver.SCHEME_FILE.equals(uri.getScheme())) { + String lastSegment = uri.getLastPathSegment(); + if (lastSegment != null) { + int lastDot = lastSegment.lastIndexOf('.'); + if (lastDot != -1) { + String extension = lastSegment.substring(lastDot + 1); + mimeType = + MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(extension.toLowerCase(Locale.ROOT)); + } + } + } + return mimeType; + } + + @Override + public Resource decode(@NonNull Uri uri, int width, int height, @NonNull Options options) + throws IOException { + Source source = ImageDecoder.createSource(context.getContentResolver(), uri); + if (Log.isLoggable(TAG, Log.VERBOSE)) { + String mimeType = context.getContentResolver().getType(uri); + Log.v( + TAG, "decoding " + uri + ", mimeType: " + mimeType + ", [" + width + ", " + height + "]"); + } + return wrapped.decode(source, width, height, options); + } +} diff --git a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/VideoDecoder.java b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/VideoDecoder.java index 110a23fbdc..39bb384dc7 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/bitmap/VideoDecoder.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/bitmap/VideoDecoder.java @@ -3,9 +3,13 @@ import android.annotation.TargetApi; import android.content.res.AssetFileDescriptor; import android.graphics.Bitmap; +import android.graphics.Matrix; import android.media.MediaDataSource; +import android.media.MediaExtractor; +import android.media.MediaFormat; import android.media.MediaMetadataRetriever; import android.os.Build; +import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; import android.os.ParcelFileDescriptor; import android.util.Log; @@ -22,6 +26,9 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.security.MessageDigest; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; /** * Decodes video data to Bitmaps from {@link ParcelFileDescriptor}s and {@link @@ -86,7 +93,7 @@ public void update( public static final Option FRAME_OPTION = Option.disk( "com.bumptech.glide.load.resource.bitmap.VideoBitmapDecode.FrameOption", - /*defaultValue=*/ MediaMetadataRetriever.OPTION_CLOSEST_SYNC, + /* defaultValue= */ MediaMetadataRetriever.OPTION_CLOSEST_SYNC, new Option.CacheKeyUpdater() { private final ByteBuffer buffer = ByteBuffer.allocate(Integer.SIZE / Byte.SIZE); @@ -110,10 +117,22 @@ public void update( private static final MediaMetadataRetrieverFactory DEFAULT_FACTORY = new MediaMetadataRetrieverFactory(); - private final MediaMetadataRetrieverInitializer initializer; + /** + * List of Pixel Android T build id prefixes missing a fix for HDR video with 180 deg rotations + * having doubly-rotated thumbnails. + * + *

More recent Android T builds should have the fix. + */ + private static final List PIXEL_T_BUILD_ID_PREFIXES_REQUIRING_HDR_180_ROTATION_FIX = + Collections.unmodifiableList(Arrays.asList("TP1A", "TD1A.220804.031")); + + private static final String WEBM_MIME_TYPE = "video/webm"; + + private final MediaInitializer initializer; private final BitmapPool bitmapPool; private final MediaMetadataRetrieverFactory factory; + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) public static ResourceDecoder asset(BitmapPool bitmapPool) { return new VideoDecoder<>(bitmapPool, new AssetFileDescriptorInitializer()); } @@ -127,14 +146,14 @@ public static ResourceDecoder byteBuffer(BitmapPool bitmapPo return new VideoDecoder<>(bitmapPool, new ByteBufferInitializer()); } - VideoDecoder(BitmapPool bitmapPool, MediaMetadataRetrieverInitializer initializer) { + VideoDecoder(BitmapPool bitmapPool, MediaInitializer initializer) { this(bitmapPool, initializer, DEFAULT_FACTORY); } @VisibleForTesting VideoDecoder( BitmapPool bitmapPool, - MediaMetadataRetrieverInitializer initializer, + MediaInitializer initializer, MediaMetadataRetrieverFactory factory) { this.bitmapPool = bitmapPool; this.initializer = initializer; @@ -170,9 +189,10 @@ public Resource decode( final Bitmap result; MediaMetadataRetriever mediaMetadataRetriever = factory.build(); try { - initializer.initialize(mediaMetadataRetriever, resource); + initializer.initializeRetriever(mediaMetadataRetriever, resource); result = decodeFrame( + resource, mediaMetadataRetriever, frameTimeMicros, frameOption, @@ -180,20 +200,29 @@ public Resource decode( outHeight, downsampleStrategy); } finally { - mediaMetadataRetriever.release(); + if (Build.VERSION.SDK_INT >= VERSION_CODES.Q) { + mediaMetadataRetriever.close(); + } else { + mediaMetadataRetriever.release(); + } } return BitmapResource.obtain(result, bitmapPool); } @Nullable - private static Bitmap decodeFrame( + private Bitmap decodeFrame( + @NonNull T resource, MediaMetadataRetriever mediaMetadataRetriever, long frameTimeMicros, int frameOption, int outWidth, int outHeight, DownsampleStrategy strategy) { + if (isUnsupportedFormat(resource, mediaMetadataRetriever)) { + throw new IllegalStateException("Cannot decode VP8 video on CrOS."); + } + Bitmap result = null; // Arguably we should handle the case where just width or just height is set to // Target.SIZE_ORIGINAL. Up to and including OMR1, MediaMetadataRetriever defaults to setting @@ -214,6 +243,11 @@ private static Bitmap decodeFrame( result = decodeOriginalFrame(mediaMetadataRetriever, frameTimeMicros, frameOption); } + // MediaMetadataRetriever has a bug where HDR videos with 180 deg rotations are rotated twice, + // causing the output frame to appear upside. This needs to be corrected for all versions of + // Android until a platform fix lands. + result = correctHdr180DegVideoFrameOrientation(mediaMetadataRetriever, result); + // Throwing an exception works better in our error logging than returning null. It shouldn't // be expensive because video decoders are attempted after image loads. Video errors are often // logged by the framework, so we can also use this error to suggest callers look for the @@ -225,6 +259,101 @@ private static Bitmap decodeFrame( return result; } + /** + * Corrects the orientation of a bitmap extracted from an HDR video with a 180 degree rotation + * angle. + * + *

This method will only return a rotated bitmap instead of the input bitmap if + * + *

    + *
  • The Android SDK level is >= R && < T OR the build id is one of T builds without the + * platform fix. + *
  • The video has a color transfer function with an HLG or ST2084 (PQ) transfer function. + *
  • The video has a color standard of BT.2020. + *
  • The video has a rotation angle of +/- 180 degrees. + *
+ */ + @TargetApi(Build.VERSION_CODES.R) + private static Bitmap correctHdr180DegVideoFrameOrientation( + MediaMetadataRetriever mediaMetadataRetriever, Bitmap frame) { + if (!isHdr180RotationFixRequired()) { + return frame; + } + boolean requiresHdr180RotationFix = false; + try { + if (isHDR(mediaMetadataRetriever)) { + String rotationString = + mediaMetadataRetriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION); + int rotation = Integer.parseInt(rotationString); + requiresHdr180RotationFix = Math.abs(rotation) == 180; + } + } catch (NumberFormatException e) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Exception trying to extract HDR transfer function or rotation"); + } + } + + if (!requiresHdr180RotationFix) { + return frame; + } + + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Applying HDR 180 deg thumbnail correction"); + } + Matrix rotationMatrix = new Matrix(); + rotationMatrix.postRotate( + /* degrees= */ 180, frame.getWidth() / 2.0f, frame.getHeight() / 2.0f); + return Bitmap.createBitmap( + frame, + /* x= */ 0, + /* y= */ 0, + frame.getWidth(), + frame.getHeight(), + rotationMatrix, + /* filter= */ true); + } + + @RequiresApi(VERSION_CODES.R) + private static boolean isHDR(MediaMetadataRetriever mediaMetadataRetriever) + throws NumberFormatException { + String colorTransferString = + mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER); + String colorStandardString = + mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD); + int colorTransfer = Integer.parseInt(colorTransferString); + int colorStandard = Integer.parseInt(colorStandardString); + // This check needs to match the isHDR check in + // frameworks/av/media/libstagefright/FrameDecoder.cpp. + return (colorTransfer == MediaFormat.COLOR_TRANSFER_HLG + || colorTransfer == MediaFormat.COLOR_TRANSFER_ST2084) + && colorStandard == MediaFormat.COLOR_STANDARD_BT2020; + } + + /** Returns true if the build requires a fix for the HDR 180 degree rotation bug. */ + @VisibleForTesting + static boolean isHdr180RotationFixRequired() { + // Only pixel devices have android T builds without the framework fix. + if (Build.MODEL.startsWith("Pixel") && VERSION.SDK_INT == VERSION_CODES.TIRAMISU) { + return isTBuildRequiringRotationFix(); + } else { + return VERSION.SDK_INT >= VERSION_CODES.R && VERSION.SDK_INT < VERSION_CODES.TIRAMISU; + } + } + + /** + * Returns true if the build is an Android T build that requires a fix for the HDR 180 degree + * rotation bug. + */ + private static boolean isTBuildRequiringRotationFix() { + for (String buildId : PIXEL_T_BUILD_ID_PREFIXES_REQUIRING_HDR_180_ROTATION_FIX) { + if (Build.ID.startsWith(buildId)) { + return true; + } + } + return false; + } + @Nullable @TargetApi(Build.VERSION_CODES.O_MR1) private static Bitmap decodeScaledFrame( @@ -284,6 +413,54 @@ private static Bitmap decodeOriginalFrame( return mediaMetadataRetriever.getFrameAtTime(frameTimeMicros, frameOption); } + /** Returns true if the format type is unsupported on the device. */ + private boolean isUnsupportedFormat( + @NonNull T resource, MediaMetadataRetriever mediaMetadataRetriever) { + // MediaFormat.KEY_MIME check below requires at least JELLY_BEAN + if (Build.VERSION.SDK_INT < VERSION_CODES.JELLY_BEAN) { + return false; + } + + // The primary known problem is vp8 video on ChromeOS (ARC) devices. + boolean isArc = Build.DEVICE != null && Build.DEVICE.matches(".+_cheets|cheets_.+"); + if (!isArc) { + return false; + } + + MediaExtractor mediaExtractor = null; + try { + // Include the MediaMetadataRetriever extract in the try block out of an abundance of caution. + String mimeType = + mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE); + if (!WEBM_MIME_TYPE.equals(mimeType)) { + return false; + } + + // Only construct a MediaExtractor for webm files, since the constructor makes a JNI call + mediaExtractor = new MediaExtractor(); + initializer.initializeExtractor(mediaExtractor, resource); + int numTracks = mediaExtractor.getTrackCount(); + for (int i = 0; i < numTracks; ++i) { + MediaFormat mediaformat = mediaExtractor.getTrackFormat(i); + String trackMimeType = mediaformat.getString(MediaFormat.KEY_MIME); + if (MediaFormat.MIMETYPE_VIDEO_VP8.equals(trackMimeType)) { + return true; + } + } + } catch (Throwable t) { + // Catching everything here out of an abundance of caution + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Exception trying to extract track info for a webm video on CrOS.", t); + } + } finally { + if (mediaExtractor != null) { + mediaExtractor.release(); + } + } + + return false; + } + @VisibleForTesting static class MediaMetadataRetrieverFactory { public MediaMetadataRetriever build() { @@ -292,56 +469,81 @@ public MediaMetadataRetriever build() { } @VisibleForTesting - interface MediaMetadataRetrieverInitializer { - void initialize(MediaMetadataRetriever retriever, T data); + interface MediaInitializer { + void initializeRetriever(MediaMetadataRetriever retriever, T data); + + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) + void initializeExtractor(MediaExtractor extractor, T data) throws IOException; } + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) private static final class AssetFileDescriptorInitializer - implements MediaMetadataRetrieverInitializer { + implements MediaInitializer { @Override - public void initialize(MediaMetadataRetriever retriever, AssetFileDescriptor data) { + public void initializeRetriever(MediaMetadataRetriever retriever, AssetFileDescriptor data) { retriever.setDataSource(data.getFileDescriptor(), data.getStartOffset(), data.getLength()); } + + @Override + public void initializeExtractor(MediaExtractor extractor, AssetFileDescriptor data) + throws IOException { + extractor.setDataSource(data.getFileDescriptor(), data.getStartOffset(), data.getLength()); + } } // Visible for VideoBitmapDecoder. static final class ParcelFileDescriptorInitializer - implements MediaMetadataRetrieverInitializer { + implements MediaInitializer { @Override - public void initialize(MediaMetadataRetriever retriever, ParcelFileDescriptor data) { + public void initializeRetriever(MediaMetadataRetriever retriever, ParcelFileDescriptor data) { retriever.setDataSource(data.getFileDescriptor()); } + + @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) + @Override + public void initializeExtractor(MediaExtractor extractor, ParcelFileDescriptor data) + throws IOException { + extractor.setDataSource(data.getFileDescriptor()); + } } @RequiresApi(Build.VERSION_CODES.M) - static final class ByteBufferInitializer - implements MediaMetadataRetrieverInitializer { + static final class ByteBufferInitializer implements MediaInitializer { @Override - public void initialize(MediaMetadataRetriever retriever, final ByteBuffer data) { - retriever.setDataSource( - new MediaDataSource() { - @Override - public int readAt(long position, byte[] buffer, int offset, int size) { - if (position >= data.limit()) { - return -1; - } - data.position((int) position); - int numBytesRead = Math.min(size, data.remaining()); - data.get(buffer, offset, numBytesRead); - return numBytesRead; - } + public void initializeRetriever(MediaMetadataRetriever retriever, final ByteBuffer data) { + retriever.setDataSource(getMediaDataSource(data)); + } - @Override - public long getSize() { - return data.limit(); - } + @Override + public void initializeExtractor(MediaExtractor extractor, final ByteBuffer data) + throws IOException { + extractor.setDataSource(getMediaDataSource(data)); + } - @Override - public void close() {} - }); + private MediaDataSource getMediaDataSource(final ByteBuffer data) { + return new MediaDataSource() { + @Override + public int readAt(long position, byte[] buffer, int offset, int size) { + if (position >= data.limit()) { + return -1; + } + data.position((int) position); + int numBytesRead = Math.min(size, data.remaining()); + data.get(buffer, offset, numBytesRead); + return numBytesRead; + } + + @Override + public long getSize() { + return data.limit(); + } + + @Override + public void close() {} + }; } } diff --git a/library/src/main/java/com/bumptech/glide/load/resource/drawable/AnimatedImageDecoder.java b/library/src/main/java/com/bumptech/glide/load/resource/drawable/AnimatedImageDecoder.java new file mode 100644 index 0000000000..2c396e7e65 --- /dev/null +++ b/library/src/main/java/com/bumptech/glide/load/resource/drawable/AnimatedImageDecoder.java @@ -0,0 +1,166 @@ +package com.bumptech.glide.load.resource.drawable; + +import android.graphics.Bitmap; +import android.graphics.ImageDecoder; +import android.graphics.ImageDecoder.Source; +import android.graphics.drawable.AnimatedImageDrawable; +import android.graphics.drawable.Drawable; +import android.os.Build; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import com.bumptech.glide.load.ImageHeaderParser; +import com.bumptech.glide.load.ImageHeaderParser.ImageType; +import com.bumptech.glide.load.ImageHeaderParserUtils; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; +import com.bumptech.glide.load.resource.DefaultOnHeaderDecodedListener; +import com.bumptech.glide.util.ByteBufferUtil; +import com.bumptech.glide.util.Synthetic; +import com.bumptech.glide.util.Util; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.List; + +/** + * Allows decoding animated images using {@link ImageDecoder}. + * + *

Supported formats: WebP on Android P+. AVIF on Android 12/S+. + */ +@RequiresApi(Build.VERSION_CODES.P) +public final class AnimatedImageDecoder { + private final List imageHeaderParsers; + private final ArrayPool arrayPool; + + public static ResourceDecoder streamDecoder( + List imageHeaderParsers, ArrayPool arrayPool) { + return new StreamAnimatedImageDecoder(new AnimatedImageDecoder(imageHeaderParsers, arrayPool)); + } + + public static ResourceDecoder byteBufferDecoder( + List imageHeaderParsers, ArrayPool arrayPool) { + return new ByteBufferAnimatedImageDecoder( + new AnimatedImageDecoder(imageHeaderParsers, arrayPool)); + } + + private AnimatedImageDecoder(List imageHeaderParsers, ArrayPool arrayPool) { + this.imageHeaderParsers = imageHeaderParsers; + this.arrayPool = arrayPool; + } + + @Synthetic + boolean handles(ByteBuffer byteBuffer) throws IOException { + return isHandled(ImageHeaderParserUtils.getType(imageHeaderParsers, byteBuffer)); + } + + @Synthetic + boolean handles(InputStream is) throws IOException { + return isHandled(ImageHeaderParserUtils.getType(imageHeaderParsers, is, arrayPool)); + } + + @SuppressWarnings("checkstyle:UnnecessaryParentheses") // Readability + private boolean isHandled(ImageType imageType) { + return imageType == ImageType.ANIMATED_WEBP + || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && imageType == ImageType.ANIMATED_AVIF); + } + + @Synthetic + Resource decode(@NonNull Source source, int width, int height, @NonNull Options options) + throws IOException { + Drawable decoded = + ImageDecoder.decodeDrawable( + source, new DefaultOnHeaderDecodedListener(width, height, options)); + if (!(decoded instanceof AnimatedImageDrawable)) { + throw new IOException( + "Received unexpected drawable type for animated image, failing: " + decoded); + } + return new AnimatedImageDrawableResource((AnimatedImageDrawable) decoded); + } + + private static final class AnimatedImageDrawableResource implements Resource { + /** A totally made up number of the number of frames we think are held in memory at once... */ + private static final int ESTIMATED_NUMBER_OF_FRAMES = 2; + + private final AnimatedImageDrawable imageDrawable; + + AnimatedImageDrawableResource(AnimatedImageDrawable imageDrawable) { + this.imageDrawable = imageDrawable; + } + + @NonNull + @Override + public Class getResourceClass() { + return Drawable.class; + } + + @NonNull + @Override + public AnimatedImageDrawable get() { + return imageDrawable; + } + + @Override + public int getSize() { + return imageDrawable.getIntrinsicWidth() + * imageDrawable.getIntrinsicHeight() + * Util.getBytesPerPixel(Bitmap.Config.ARGB_8888) + * ESTIMATED_NUMBER_OF_FRAMES; + } + + @Override + public void recycle() { + imageDrawable.stop(); + imageDrawable.clearAnimationCallbacks(); + } + } + + private static final class StreamAnimatedImageDecoder + implements ResourceDecoder { + + private final AnimatedImageDecoder delegate; + + StreamAnimatedImageDecoder(AnimatedImageDecoder delegate) { + this.delegate = delegate; + } + + @Override + public boolean handles(@NonNull InputStream source, @NonNull Options options) + throws IOException { + return delegate.handles(source); + } + + @Override + public Resource decode( + @NonNull InputStream is, int width, int height, @NonNull Options options) + throws IOException { + Source source = ImageDecoder.createSource(ByteBufferUtil.fromStream(is)); + return delegate.decode(source, width, height, options); + } + } + + private static final class ByteBufferAnimatedImageDecoder + implements ResourceDecoder { + + private final AnimatedImageDecoder delegate; + + ByteBufferAnimatedImageDecoder(AnimatedImageDecoder delegate) { + this.delegate = delegate; + } + + @Override + public boolean handles(@NonNull ByteBuffer source, @NonNull Options options) + throws IOException { + return delegate.handles(source); + } + + @Override + public Resource decode( + @NonNull ByteBuffer byteBuffer, int width, int height, @NonNull Options options) + throws IOException { + Source source = ImageDecoder.createSource(byteBuffer); + return delegate.decode(source, width, height, options); + } + } +} diff --git a/library/src/main/java/com/bumptech/glide/load/resource/drawable/AnimatedWebpDecoder.java b/library/src/main/java/com/bumptech/glide/load/resource/drawable/AnimatedWebpDecoder.java new file mode 100644 index 0000000000..bc5497b453 --- /dev/null +++ b/library/src/main/java/com/bumptech/glide/load/resource/drawable/AnimatedWebpDecoder.java @@ -0,0 +1,165 @@ +package com.bumptech.glide.load.resource.drawable; + +import android.graphics.Bitmap; +import android.graphics.ImageDecoder; +import android.graphics.ImageDecoder.Source; +import android.graphics.drawable.AnimatedImageDrawable; +import android.graphics.drawable.Drawable; +import android.os.Build; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import com.bumptech.glide.load.ImageHeaderParser; +import com.bumptech.glide.load.ImageHeaderParser.ImageType; +import com.bumptech.glide.load.ImageHeaderParserUtils; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.ResourceDecoder; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; +import com.bumptech.glide.load.resource.DefaultOnHeaderDecodedListener; +import com.bumptech.glide.util.ByteBufferUtil; +import com.bumptech.glide.util.Synthetic; +import com.bumptech.glide.util.Util; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.List; + +/** + * Allows decoding animated webp images using {@link ImageDecoder} on Android P+. @Deprecated This + * class has been replaced by {@link AnimatedImageDecoder} and is not used in Glide by default. It + * will be removed in a future version. + */ +@Deprecated +@RequiresApi(Build.VERSION_CODES.P) +public final class AnimatedWebpDecoder { + private final List imageHeaderParsers; + private final ArrayPool arrayPool; + + public static ResourceDecoder streamDecoder( + List imageHeaderParsers, ArrayPool arrayPool) { + return new StreamAnimatedWebpDecoder(new AnimatedWebpDecoder(imageHeaderParsers, arrayPool)); + } + + public static ResourceDecoder byteBufferDecoder( + List imageHeaderParsers, ArrayPool arrayPool) { + return new ByteBufferAnimatedWebpDecoder( + new AnimatedWebpDecoder(imageHeaderParsers, arrayPool)); + } + + private AnimatedWebpDecoder(List imageHeaderParsers, ArrayPool arrayPool) { + this.imageHeaderParsers = imageHeaderParsers; + this.arrayPool = arrayPool; + } + + @Synthetic + boolean handles(ByteBuffer byteBuffer) throws IOException { + return isHandled(ImageHeaderParserUtils.getType(imageHeaderParsers, byteBuffer)); + } + + @Synthetic + boolean handles(InputStream is) throws IOException { + return isHandled(ImageHeaderParserUtils.getType(imageHeaderParsers, is, arrayPool)); + } + + private boolean isHandled(ImageType imageType) { + return imageType == ImageType.ANIMATED_WEBP; + } + + @Synthetic + Resource decode(@NonNull Source source, int width, int height, @NonNull Options options) + throws IOException { + Drawable decoded = + ImageDecoder.decodeDrawable( + source, new DefaultOnHeaderDecodedListener(width, height, options)); + if (!(decoded instanceof AnimatedImageDrawable)) { + throw new IOException( + "Received unexpected drawable type for animated webp, failing: " + decoded); + } + return new AnimatedImageDrawableResource((AnimatedImageDrawable) decoded); + } + + private static final class AnimatedImageDrawableResource implements Resource { + /** A totally made up number of the number of frames we think are held in memory at once... */ + private static final int ESTIMATED_NUMBER_OF_FRAMES = 2; + + private final AnimatedImageDrawable imageDrawable; + + AnimatedImageDrawableResource(AnimatedImageDrawable imageDrawable) { + this.imageDrawable = imageDrawable; + } + + @NonNull + @Override + public Class getResourceClass() { + return Drawable.class; + } + + @NonNull + @Override + public AnimatedImageDrawable get() { + return imageDrawable; + } + + @Override + public int getSize() { + return imageDrawable.getIntrinsicWidth() + * imageDrawable.getIntrinsicHeight() + * Util.getBytesPerPixel(Bitmap.Config.ARGB_8888) + * ESTIMATED_NUMBER_OF_FRAMES; + } + + @Override + public void recycle() { + imageDrawable.stop(); + imageDrawable.clearAnimationCallbacks(); + } + } + + private static final class StreamAnimatedWebpDecoder + implements ResourceDecoder { + + private final AnimatedWebpDecoder delegate; + + StreamAnimatedWebpDecoder(AnimatedWebpDecoder delegate) { + this.delegate = delegate; + } + + @Override + public boolean handles(@NonNull InputStream source, @NonNull Options options) + throws IOException { + return delegate.handles(source); + } + + @Override + public Resource decode( + @NonNull InputStream is, int width, int height, @NonNull Options options) + throws IOException { + Source source = ImageDecoder.createSource(ByteBufferUtil.fromStream(is)); + return delegate.decode(source, width, height, options); + } + } + + private static final class ByteBufferAnimatedWebpDecoder + implements ResourceDecoder { + + private final AnimatedWebpDecoder delegate; + + ByteBufferAnimatedWebpDecoder(AnimatedWebpDecoder delegate) { + this.delegate = delegate; + } + + @Override + public boolean handles(@NonNull ByteBuffer source, @NonNull Options options) + throws IOException { + return delegate.handles(source); + } + + @Override + public Resource decode( + @NonNull ByteBuffer byteBuffer, int width, int height, @NonNull Options options) + throws IOException { + Source source = ImageDecoder.createSource(byteBuffer); + return delegate.decode(source, width, height, options); + } + } +} diff --git a/library/src/main/java/com/bumptech/glide/load/resource/drawable/DrawableDecoderCompat.java b/library/src/main/java/com/bumptech/glide/load/resource/drawable/DrawableDecoderCompat.java index 405cbe9813..8b4f477ceb 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/drawable/DrawableDecoderCompat.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/drawable/DrawableDecoderCompat.java @@ -4,6 +4,8 @@ import android.content.res.Resources; import android.content.res.Resources.Theme; import android.graphics.drawable.Drawable; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; import androidx.annotation.DrawableRes; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; @@ -25,7 +27,7 @@ private DrawableDecoderCompat() { /** See {@code getDrawable(Context, int, Theme)}. */ public static Drawable getDrawable( Context ourContext, Context targetContext, @DrawableRes int id) { - return getDrawable(ourContext, targetContext, id, /*theme=*/ null); + return getDrawable(ourContext, targetContext, id, /* theme= */ null); } /** @@ -65,13 +67,19 @@ private static Drawable getDrawable( private static Drawable loadDrawableV7( Context context, @DrawableRes int id, @Nullable Theme theme) { - Context resourceContext = theme != null ? new ContextThemeWrapper(context, theme) : context; + Context resourceContext; + if (theme != null && VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + ContextThemeWrapper contextThemeWrapper = new ContextThemeWrapper(context, theme); + contextThemeWrapper.applyOverrideConfiguration(theme.getResources().getConfiguration()); + resourceContext = contextThemeWrapper; + } else { + resourceContext = context; + } return AppCompatResources.getDrawable(resourceContext, id); } private static Drawable loadDrawableV4( Context context, @DrawableRes int id, @Nullable Theme theme) { - Resources resources = context.getResources(); - return ResourcesCompat.getDrawable(resources, id, theme); + return ResourcesCompat.getDrawable(context.getResources(), id, theme); } } diff --git a/library/src/main/java/com/bumptech/glide/load/resource/drawable/DrawableTransitionOptions.java b/library/src/main/java/com/bumptech/glide/load/resource/drawable/DrawableTransitionOptions.java index d05077a834..bc459292e0 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/drawable/DrawableTransitionOptions.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/drawable/DrawableTransitionOptions.java @@ -105,4 +105,18 @@ public DrawableTransitionOptions crossFade( public DrawableTransitionOptions crossFade(@NonNull DrawableCrossFadeFactory.Builder builder) { return crossFade(builder.build()); } + + // Make sure that we're not equal to any other concrete implementation of TransitionOptions. + @Override + public boolean equals(Object o) { + return o instanceof DrawableTransitionOptions && super.equals(o); + } + + // Our class doesn't include any additional properties, so we don't need to modify hashcode, but + // keep it here as a reminder in case we add properties. + @SuppressWarnings("PMD.UselessOverridingMethod") + @Override + public int hashCode() { + return super.hashCode(); + } } diff --git a/library/src/main/java/com/bumptech/glide/load/resource/drawable/ResourceDrawableDecoder.java b/library/src/main/java/com/bumptech/glide/load/resource/drawable/ResourceDrawableDecoder.java index 4c7132cf95..d363f77ede 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/drawable/ResourceDrawableDecoder.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/drawable/ResourceDrawableDecoder.java @@ -4,14 +4,18 @@ import android.content.Context; import android.content.pm.PackageManager.NameNotFoundException; import android.content.res.Resources; +import android.content.res.Resources.Theme; import android.graphics.drawable.Drawable; import android.net.Uri; +import android.text.TextUtils; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.bumptech.glide.load.Option; import com.bumptech.glide.load.Options; import com.bumptech.glide.load.ResourceDecoder; import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.util.Preconditions; import java.util.List; /** @@ -23,6 +27,11 @@ * other packages. */ public class ResourceDrawableDecoder implements ResourceDecoder { + + /** Specifies a {@link Theme} which will be used to load the drawable. */ + public static final Option THEME = + Option.memory("com.bumptech.glide.load.resource.bitmap.Downsampler.Theme"); + /** * The package name to provide {@link Resources#getIdentifier(String, String, String)} when trying * to find system resource ids. @@ -30,11 +39,13 @@ public class ResourceDrawableDecoder implements ResourceDecoder { *

As far as I can tell this is undocumented, but works. */ private static final String ANDROID_PACKAGE_NAME = "android"; + /** * {@link Resources#getIdentifier(String, String, String)} documents that it will return 0 and * that 0 is not a valid resouce id. */ private static final int MISSING_RESOURCE_ID = 0; + // android.resource:////. private static final int NAME_URI_PATH_SEGMENTS = 2; private static final int TYPE_PATH_SEGMENT_INDEX = 0; @@ -51,7 +62,8 @@ public ResourceDrawableDecoder(Context context) { @Override public boolean handles(@NonNull Uri source, @NonNull Options options) { - return source.getScheme().equals(ContentResolver.SCHEME_ANDROID_RESOURCE); + String scheme = source.getScheme(); + return scheme != null && scheme.equals(ContentResolver.SCHEME_ANDROID_RESOURCE); } @Nullable @@ -59,22 +71,34 @@ public boolean handles(@NonNull Uri source, @NonNull Options options) { public Resource decode( @NonNull Uri source, int width, int height, @NonNull Options options) { String packageName = source.getAuthority(); + if (TextUtils.isEmpty(packageName)) { + throw new IllegalStateException("Package name for " + source + " is null or empty"); + } Context targetContext = findContextForPackage(source, packageName); @DrawableRes int resId = findResourceIdFromUri(targetContext, source); - // We can't get a theme from another application. - Drawable drawable = DrawableDecoderCompat.getDrawable(context, targetContext, resId); + // Only use the provided theme if we're loading resources from our package. We can't get themes + // from other packages and we don't want to use a theme from our package when loading another + // package's resources. + Theme theme = + Preconditions.checkNotNull(packageName).equals(context.getPackageName()) + ? options.get(THEME) + : null; + Drawable drawable = + theme == null + ? DrawableDecoderCompat.getDrawable(context, targetContext, resId) + : DrawableDecoderCompat.getDrawable(context, resId, theme); return NonOwnedDrawableResource.newInstance(drawable); } @NonNull - private Context findContextForPackage(Uri source, String packageName) { + private Context findContextForPackage(Uri source, @NonNull String packageName) { // Fast path if (packageName.equals(context.getPackageName())) { return context; } try { - return context.createPackageContext(packageName, /*flags=*/ 0); + return context.createPackageContext(packageName, /* flags= */ 0); } catch (NameNotFoundException e) { // The parent APK holds the correct context if the resource is located in a split if (packageName.contains(context.getPackageName())) { diff --git a/library/src/main/java/com/bumptech/glide/load/resource/gif/GifBitmapProvider.java b/library/src/main/java/com/bumptech/glide/load/resource/gif/GifBitmapProvider.java index 8060fc95a9..1c5b85152b 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/gif/GifBitmapProvider.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/gif/GifBitmapProvider.java @@ -20,7 +20,7 @@ public final class GifBitmapProvider implements GifDecoder.BitmapProvider { * when requested. */ public GifBitmapProvider(BitmapPool bitmapPool) { - this(bitmapPool, /*arrayPool=*/ null); + this(bitmapPool, /* arrayPool= */ null); } /** Constructs an instance with a shared array pool. Arrays will be reused where possible. */ diff --git a/library/src/main/java/com/bumptech/glide/load/resource/gif/GifDrawable.java b/library/src/main/java/com/bumptech/glide/load/resource/gif/GifDrawable.java index d62a48437c..c469d6dbec 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/gif/GifDrawable.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/gif/GifDrawable.java @@ -34,6 +34,7 @@ public class GifDrawable extends Drawable // Public API. @SuppressWarnings("WeakerAccess") public static final int LOOP_FOREVER = -1; + /** * A constant indicating that an animated drawable should loop for its default number of times. * For animated GIFs, this constant indicates the GIF should use the netscape loop count if @@ -46,12 +47,16 @@ public class GifDrawable extends Drawable private static final int GRAVITY = Gravity.FILL; private final GifState state; + /** True if the drawable is currently animating. */ private boolean isRunning; + /** True if the drawable should animate while visible. */ private boolean isStarted; + /** True if the drawable's resources have been recycled. */ private boolean isRecycled; + /** * True if the drawable is currently visible. Default to true because on certain platforms (at * least 4.1.1), setVisible is not called on {@link android.graphics.drawable.Drawable Drawables} @@ -59,8 +64,10 @@ public class GifDrawable extends Drawable * See issue #130. */ private boolean isVisible = true; + /** The number of times we've looped over all the frames in the GIF. */ private int loopCount; + /** The number of times to loop through the GIF animation. */ private int maxLoopCount = LOOP_FOREVER; @@ -345,8 +352,8 @@ public void onFrameReady() { } if (maxLoopCount != LOOP_FOREVER && loopCount >= maxLoopCount) { - notifyAnimationEndToListeners(); stop(); + notifyAnimationEndToListeners(); } } diff --git a/library/src/main/java/com/bumptech/glide/load/resource/transcode/BitmapDrawableTranscoder.java b/library/src/main/java/com/bumptech/glide/load/resource/transcode/BitmapDrawableTranscoder.java index c4abeed729..245c2e2a71 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/transcode/BitmapDrawableTranscoder.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/transcode/BitmapDrawableTranscoder.java @@ -25,7 +25,9 @@ public BitmapDrawableTranscoder(@NonNull Context context) { this(context.getResources()); } - /** @deprecated Use {@link #BitmapDrawableTranscoder(Resources)}, {@code bitmapPool} is unused. */ + /** + * @deprecated Use {@link #BitmapDrawableTranscoder(Resources)}, {@code bitmapPool} is unused. + */ @Deprecated public BitmapDrawableTranscoder( @NonNull Resources resources, @SuppressWarnings("unused") BitmapPool bitmapPool) { diff --git a/library/src/main/java/com/bumptech/glide/load/resource/transcode/TranscoderRegistry.java b/library/src/main/java/com/bumptech/glide/load/resource/transcode/TranscoderRegistry.java index 18bd05f16d..7a595173ff 100644 --- a/library/src/main/java/com/bumptech/glide/load/resource/transcode/TranscoderRegistry.java +++ b/library/src/main/java/com/bumptech/glide/load/resource/transcode/TranscoderRegistry.java @@ -60,6 +60,7 @@ public synchronized ResourceTranscoder get( } @NonNull + @SuppressWarnings("unchecked") public synchronized List> getTranscodeClasses( @NonNull Class resourceClass, @NonNull Class transcodeClass) { List> transcodeClasses = new ArrayList<>(); @@ -70,8 +71,9 @@ public synchronized List> getTranscodeClasses( } for (Entry entry : transcoders) { - if (entry.handles(resourceClass, transcodeClass)) { - transcodeClasses.add(transcodeClass); + if (entry.handles(resourceClass, transcodeClass) + && !transcodeClasses.contains((Class) entry.toClass)) { + transcodeClasses.add((Class) entry.toClass); } } @@ -79,8 +81,8 @@ public synchronized List> getTranscodeClasses( } private static final class Entry { - private final Class fromClass; - private final Class toClass; + @Synthetic final Class fromClass; + @Synthetic final Class toClass; @Synthetic final ResourceTranscoder transcoder; Entry( diff --git a/library/src/main/java/com/bumptech/glide/manager/ActivityFragmentLifecycle.java b/library/src/main/java/com/bumptech/glide/manager/ActivityFragmentLifecycle.java deleted file mode 100644 index 120278c594..0000000000 --- a/library/src/main/java/com/bumptech/glide/manager/ActivityFragmentLifecycle.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.bumptech.glide.manager; - -import androidx.annotation.NonNull; -import com.bumptech.glide.util.Util; -import java.util.Collections; -import java.util.Set; -import java.util.WeakHashMap; - -/** - * A {@link com.bumptech.glide.manager.Lifecycle} implementation for tracking and notifying - * listeners of {@link android.app.Fragment} and {@link android.app.Activity} lifecycle events. - */ -class ActivityFragmentLifecycle implements Lifecycle { - private final Set lifecycleListeners = - Collections.newSetFromMap(new WeakHashMap()); - private boolean isStarted; - private boolean isDestroyed; - - /** - * Adds the given listener to the list of listeners to be notified on each lifecycle event. - * - *

The latest lifecycle event will be called on the given listener synchronously in this - * method. If the activity or fragment is stopped, {@link LifecycleListener#onStop()}} will be - * called, and same for onStart and onDestroy. - * - *

Note - {@link com.bumptech.glide.manager.LifecycleListener}s that are added more than once - * will have their lifecycle methods called more than once. It is the caller's responsibility to - * avoid adding listeners multiple times. - */ - @Override - public void addListener(@NonNull LifecycleListener listener) { - lifecycleListeners.add(listener); - - if (isDestroyed) { - listener.onDestroy(); - } else if (isStarted) { - listener.onStart(); - } else { - listener.onStop(); - } - } - - @Override - public void removeListener(@NonNull LifecycleListener listener) { - lifecycleListeners.remove(listener); - } - - void onStart() { - isStarted = true; - for (LifecycleListener lifecycleListener : Util.getSnapshot(lifecycleListeners)) { - lifecycleListener.onStart(); - } - } - - void onStop() { - isStarted = false; - for (LifecycleListener lifecycleListener : Util.getSnapshot(lifecycleListeners)) { - lifecycleListener.onStop(); - } - } - - void onDestroy() { - isDestroyed = true; - for (LifecycleListener lifecycleListener : Util.getSnapshot(lifecycleListeners)) { - lifecycleListener.onDestroy(); - } - } -} diff --git a/library/src/main/java/com/bumptech/glide/manager/DefaultConnectivityMonitor.java b/library/src/main/java/com/bumptech/glide/manager/DefaultConnectivityMonitor.java index e28201bd0a..999ea8dc84 100644 --- a/library/src/main/java/com/bumptech/glide/manager/DefaultConnectivityMonitor.java +++ b/library/src/main/java/com/bumptech/glide/manager/DefaultConnectivityMonitor.java @@ -1,104 +1,32 @@ package com.bumptech.glide.manager; -import android.annotation.SuppressLint; -import android.content.BroadcastReceiver; import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.util.Log; import androidx.annotation.NonNull; -import com.bumptech.glide.util.Preconditions; import com.bumptech.glide.util.Synthetic; -/** Uses {@link android.net.ConnectivityManager} to identify connectivity changes. */ +/** + * An Android Lifecycle wrapper that uses {@link SingletonConnectivityReceiver} to observer + * connectivity changes, allowing for registration to be removed when our listener is being + * destroyed as part of the Android lifecycle. + */ final class DefaultConnectivityMonitor implements ConnectivityMonitor { - private static final String TAG = "ConnectivityMonitor"; private final Context context; @SuppressWarnings("WeakerAccess") @Synthetic final ConnectivityListener listener; - @SuppressWarnings("WeakerAccess") - @Synthetic - boolean isConnected; - - private boolean isRegistered; - - private final BroadcastReceiver connectivityReceiver = - new BroadcastReceiver() { - @Override - public void onReceive(@NonNull Context context, Intent intent) { - boolean wasConnected = isConnected; - isConnected = isConnected(context); - if (wasConnected != isConnected) { - if (Log.isLoggable(TAG, Log.DEBUG)) { - Log.d(TAG, "connectivity changed, isConnected: " + isConnected); - } - - listener.onConnectivityChanged(isConnected); - } - } - }; - DefaultConnectivityMonitor(@NonNull Context context, @NonNull ConnectivityListener listener) { this.context = context.getApplicationContext(); this.listener = listener; } private void register() { - if (isRegistered) { - return; - } - - // Initialize isConnected. - isConnected = isConnected(context); - try { - // See #1405 - context.registerReceiver( - connectivityReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); - isRegistered = true; - } catch (SecurityException e) { - // See #1417, registering the receiver can throw SecurityException. - if (Log.isLoggable(TAG, Log.WARN)) { - Log.w(TAG, "Failed to register", e); - } - } + SingletonConnectivityReceiver.get(context).register(listener); } private void unregister() { - if (!isRegistered) { - return; - } - - context.unregisterReceiver(connectivityReceiver); - isRegistered = false; - } - - @SuppressWarnings("WeakerAccess") - @Synthetic - // Permissions are checked in the factory instead. - @SuppressLint("MissingPermission") - boolean isConnected(@NonNull Context context) { - ConnectivityManager connectivityManager = - Preconditions.checkNotNull( - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); - NetworkInfo networkInfo; - try { - networkInfo = connectivityManager.getActiveNetworkInfo(); - } catch (RuntimeException e) { - // #1405 shows that this throws a SecurityException. - // b/70869360 shows that this throws NullPointerException on APIs 22, 23, and 24. - // b/70869360 also shows that this throws RuntimeException on API 24 and 25. - if (Log.isLoggable(TAG, Log.WARN)) { - Log.w(TAG, "Failed to determine connectivity status when connectivity changed", e); - } - // Default to true; - return true; - } - return networkInfo != null && networkInfo.isConnected(); + SingletonConnectivityReceiver.get(context).unregister(listener); } @Override diff --git a/library/src/main/java/com/bumptech/glide/manager/FirstFrameAndAfterTrimMemoryWaiter.java b/library/src/main/java/com/bumptech/glide/manager/FirstFrameAndAfterTrimMemoryWaiter.java deleted file mode 100644 index 085b9c3bd2..0000000000 --- a/library/src/main/java/com/bumptech/glide/manager/FirstFrameAndAfterTrimMemoryWaiter.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.bumptech.glide.manager; - -import android.app.Activity; -import android.content.ComponentCallbacks2; -import android.content.res.Configuration; -import android.os.Build; -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; - -@RequiresApi(Build.VERSION_CODES.O) -final class FirstFrameAndAfterTrimMemoryWaiter implements FrameWaiter, ComponentCallbacks2 { - - @Override - public void registerSelf(Activity activity) {} - - @Override - public void onTrimMemory(int level) {} - - @Override - public void onConfigurationChanged(@NonNull Configuration newConfig) {} - - @Override - public void onLowMemory() { - onTrimMemory(TRIM_MEMORY_UI_HIDDEN); - } -} diff --git a/library/src/main/java/com/bumptech/glide/manager/FirstFrameWaiter.java b/library/src/main/java/com/bumptech/glide/manager/FirstFrameWaiter.java index a6f44c1900..cbc9f2c44b 100644 --- a/library/src/main/java/com/bumptech/glide/manager/FirstFrameWaiter.java +++ b/library/src/main/java/com/bumptech/glide/manager/FirstFrameWaiter.java @@ -2,11 +2,67 @@ import android.app.Activity; import android.os.Build; +import android.view.View; +import android.view.ViewTreeObserver; +import android.view.ViewTreeObserver.OnDrawListener; import androidx.annotation.RequiresApi; +import com.bumptech.glide.load.resource.bitmap.HardwareConfigState; +import com.bumptech.glide.util.Synthetic; +import com.bumptech.glide.util.Util; +import java.util.Collections; +import java.util.Set; +import java.util.WeakHashMap; @RequiresApi(Build.VERSION_CODES.O) final class FirstFrameWaiter implements FrameWaiter { + @Synthetic + final Set pendingActivities = + Collections.newSetFromMap(new WeakHashMap()); + + @Synthetic volatile boolean isFirstFrameSet; @Override - public void registerSelf(Activity activity) {} + public void registerSelf(Activity activity) { + // It's possible we'll create a few of these, but it's not particularly expensive to do so and + // we'd rather work around any edge cases that might prevent the first Activity we listen to + // from firing our callback ever. + if (isFirstFrameSet) { + return; + } + if (!pendingActivities.add(activity)) { + return; + } + + final View view = activity.getWindow().getDecorView(); + ViewTreeObserver viewTreeObserver = view.getViewTreeObserver(); + viewTreeObserver.addOnDrawListener( + new OnDrawListener() { + @Override + public void onDraw() { + // We can't remove the listener during onDraw, so always post the removal to the UI + // thread, even if the first frame may already be set before our listener goes off. + final OnDrawListener listener = this; + Util.postOnUiThread( + new Runnable() { + @Override + public void run() { + HardwareConfigState.getInstance().unblockHardwareBitmaps(); + isFirstFrameSet = true; + removeListener(view, listener); + pendingActivities.clear(); + } + }); + } + }); + } + + @Synthetic + static void removeListener(View view, OnDrawListener listener) { + // The original ViewTreeObserver might be merged into a new one and be dead. + // Since we have to handle that case anyway, We might as well always just + // obtain the current observer and use a single code path. + // We also have to wait to remove this because we're being called in onDraw. + ViewTreeObserver currentViewTreeObserver = view.getViewTreeObserver(); + currentViewTreeObserver.removeOnDrawListener(listener); + } } diff --git a/library/src/main/java/com/bumptech/glide/manager/LifecycleLifecycle.java b/library/src/main/java/com/bumptech/glide/manager/LifecycleLifecycle.java new file mode 100644 index 0000000000..fb455aa4e4 --- /dev/null +++ b/library/src/main/java/com/bumptech/glide/manager/LifecycleLifecycle.java @@ -0,0 +1,64 @@ +package com.bumptech.glide.manager; + +import androidx.annotation.NonNull; +import androidx.lifecycle.Lifecycle.Event; +import androidx.lifecycle.Lifecycle.State; +import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.OnLifecycleEvent; +import com.bumptech.glide.util.Util; +import java.util.HashSet; +import java.util.Set; + +@SuppressWarnings("OnLifecycleEvent") // Glide doesn't support Java 8 +final class LifecycleLifecycle implements Lifecycle, LifecycleObserver { + @NonNull + private final Set lifecycleListeners = new HashSet(); + + @NonNull private final androidx.lifecycle.Lifecycle lifecycle; + + LifecycleLifecycle(androidx.lifecycle.Lifecycle lifecycle) { + this.lifecycle = lifecycle; + lifecycle.addObserver(this); + } + + @OnLifecycleEvent(Event.ON_START) + public void onStart(@NonNull LifecycleOwner owner) { + for (LifecycleListener lifecycleListener : Util.getSnapshot(lifecycleListeners)) { + lifecycleListener.onStart(); + } + } + + @OnLifecycleEvent(Event.ON_STOP) + public void onStop(@NonNull LifecycleOwner owner) { + for (LifecycleListener lifecycleListener : Util.getSnapshot(lifecycleListeners)) { + lifecycleListener.onStop(); + } + } + + @OnLifecycleEvent(Event.ON_DESTROY) + public void onDestroy(@NonNull LifecycleOwner owner) { + for (LifecycleListener lifecycleListener : Util.getSnapshot(lifecycleListeners)) { + lifecycleListener.onDestroy(); + } + owner.getLifecycle().removeObserver(this); + } + + @Override + public void addListener(@NonNull LifecycleListener listener) { + lifecycleListeners.add(listener); + + if (lifecycle.getCurrentState() == State.DESTROYED) { + listener.onDestroy(); + } else if (lifecycle.getCurrentState().isAtLeast(State.STARTED)) { + listener.onStart(); + } else { + listener.onStop(); + } + } + + @Override + public void removeListener(@NonNull LifecycleListener listener) { + lifecycleListeners.remove(listener); + } +} diff --git a/library/src/main/java/com/bumptech/glide/manager/LifecycleRequestManagerRetriever.java b/library/src/main/java/com/bumptech/glide/manager/LifecycleRequestManagerRetriever.java new file mode 100644 index 0000000000..b19212d07d --- /dev/null +++ b/library/src/main/java/com/bumptech/glide/manager/LifecycleRequestManagerRetriever.java @@ -0,0 +1,100 @@ +package com.bumptech.glide.manager; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.Lifecycle; +import com.bumptech.glide.Glide; +import com.bumptech.glide.RequestManager; +import com.bumptech.glide.manager.RequestManagerRetriever.RequestManagerFactory; +import com.bumptech.glide.util.Synthetic; +import com.bumptech.glide.util.Util; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class LifecycleRequestManagerRetriever { + @Synthetic final Map lifecycleToRequestManager = new HashMap<>(); + @NonNull private final RequestManagerFactory factory; + + LifecycleRequestManagerRetriever(@NonNull RequestManagerFactory factory) { + this.factory = factory; + } + + RequestManager getOnly(Lifecycle lifecycle) { + Util.assertMainThread(); + return lifecycleToRequestManager.get(lifecycle); + } + + RequestManager getOrCreate( + Context context, + Glide glide, + final Lifecycle lifecycle, + FragmentManager childFragmentManager, + boolean isParentVisible) { + Util.assertMainThread(); + RequestManager result = getOnly(lifecycle); + if (result == null) { + LifecycleLifecycle glideLifecycle = new LifecycleLifecycle(lifecycle); + result = + factory.build( + glide, + glideLifecycle, + new SupportRequestManagerTreeNode(childFragmentManager), + context); + lifecycleToRequestManager.put(lifecycle, result); + glideLifecycle.addListener( + new LifecycleListener() { + @Override + public void onStart() {} + + @Override + public void onStop() {} + + @Override + public void onDestroy() { + lifecycleToRequestManager.remove(lifecycle); + } + }); + // This is a bit of hack, we're going to start the RequestManager, but not the + // corresponding Lifecycle. It's safe to start the RequestManager, but starting the + // Lifecycle might trigger memory leaks. See b/154405040 + if (isParentVisible) { + result.onStart(); + } + } + return result; + } + + private final class SupportRequestManagerTreeNode implements RequestManagerTreeNode { + private final FragmentManager childFragmentManager; + + SupportRequestManagerTreeNode(FragmentManager childFragmentManager) { + this.childFragmentManager = childFragmentManager; + } + + @NonNull + @Override + public Set getDescendants() { + Set result = new HashSet<>(); + getChildFragmentsRecursive(childFragmentManager, result); + return result; + } + + private void getChildFragmentsRecursive( + FragmentManager fragmentManager, Set requestManagers) { + List children = fragmentManager.getFragments(); + for (int i = 0, size = children.size(); i < size; i++) { + Fragment child = children.get(i); + getChildFragmentsRecursive(child.getChildFragmentManager(), requestManagers); + RequestManager fromChild = getOnly(child.getLifecycle()); + if (fromChild != null) { + requestManagers.add(fromChild); + } + } + } + } +} diff --git a/library/src/main/java/com/bumptech/glide/manager/RequestManagerFragment.java b/library/src/main/java/com/bumptech/glide/manager/RequestManagerFragment.java index b3d26fa189..3f243b220b 100644 --- a/library/src/main/java/com/bumptech/glide/manager/RequestManagerFragment.java +++ b/library/src/main/java/com/bumptech/glide/manager/RequestManagerFragment.java @@ -1,244 +1,47 @@ package com.bumptech.glide.manager; -import android.annotation.SuppressLint; -import android.annotation.TargetApi; -import android.app.Activity; import android.app.Fragment; -import android.os.Build; -import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import com.bumptech.glide.Glide; import com.bumptech.glide.RequestManager; -import com.bumptech.glide.util.Synthetic; import java.util.Collections; -import java.util.HashSet; import java.util.Set; /** - * A view-less {@link android.app.Fragment} used to safely store an {@link - * com.bumptech.glide.RequestManager} that can be used to start, stop and manage Glide requests - * started for targets the fragment or activity this fragment is a child of. - * - * @see com.bumptech.glide.manager.SupportRequestManagerFragment - * @see com.bumptech.glide.manager.RequestManagerRetriever - * @see com.bumptech.glide.RequestManager + * @deprecated This class is unused by Glide and contains only no-op methods. It's retained along + * with its public methods to avoid breaking binary compatibility. Lifecycle integration is no + * longer supported outside of androidx Activitys and Fragments. */ -@SuppressWarnings("DeprecatedIsStillUsed") @Deprecated public class RequestManagerFragment extends Fragment { - private static final String TAG = "RMFragment"; - private final ActivityFragmentLifecycle lifecycle; - private final RequestManagerTreeNode requestManagerTreeNode = - new FragmentRequestManagerTreeNode(); - - @SuppressWarnings("deprecation") - private final Set childRequestManagerFragments = new HashSet<>(); - - @Nullable private RequestManager requestManager; - - @SuppressWarnings("deprecation") - @Nullable - private RequestManagerFragment rootRequestManagerFragment; - - @Nullable private Fragment parentFragmentHint; - - public RequestManagerFragment() { - this(new ActivityFragmentLifecycle()); - } - - @VisibleForTesting - @SuppressLint("ValidFragment") - RequestManagerFragment(@NonNull ActivityFragmentLifecycle lifecycle) { - this.lifecycle = lifecycle; - } - /** - * Sets the current {@link com.bumptech.glide.RequestManager}. - * - * @param requestManager The request manager to use. + * @deprecated This method is a no-op. See the class comment for deprecation details. */ - public void setRequestManager(@Nullable RequestManager requestManager) { - this.requestManager = requestManager; - } + @Deprecated + public void setRequestManager(@Nullable RequestManager requestManager) {} - @NonNull - ActivityFragmentLifecycle getGlideLifecycle() { - return lifecycle; - } - - /** Returns the current {@link com.bumptech.glide.RequestManager} or null if none exists. */ + /** + * @deprecated This always returns null. See the class comment for deprecation details. + */ + @Deprecated @Nullable public RequestManager getRequestManager() { - return requestManager; - } - - /** Returns the {@link RequestManagerTreeNode} for this fragment. */ - @NonNull - public RequestManagerTreeNode getRequestManagerTreeNode() { - return requestManagerTreeNode; - } - - @SuppressWarnings("deprecation") - private void addChildRequestManagerFragment(RequestManagerFragment child) { - childRequestManagerFragments.add(child); - } - - @SuppressWarnings("deprecation") - private void removeChildRequestManagerFragment(RequestManagerFragment child) { - childRequestManagerFragments.remove(child); + return null; } /** - * Returns the set of fragments that this RequestManagerFragment's parent is a parent to. (i.e. - * our parent is the fragment that we are annotating). + * @deprecated This always returns an empty tree node. See the class comment for deprecation + * details. */ - @SuppressWarnings("deprecation") - @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) - @Synthetic + @Deprecated @NonNull - Set getDescendantRequestManagerFragments() { - if (equals(rootRequestManagerFragment)) { - return Collections.unmodifiableSet(childRequestManagerFragments); - } else if (rootRequestManagerFragment == null - || Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { - // Pre JB MR1 doesn't allow us to get the parent fragment so we can't introspect hierarchy, - // so just return an empty set. - return Collections.emptySet(); - } else { - Set descendants = new HashSet<>(); - for (RequestManagerFragment fragment : - rootRequestManagerFragment.getDescendantRequestManagerFragments()) { - if (isDescendant(fragment.getParentFragment())) { - descendants.add(fragment); - } - } - return Collections.unmodifiableSet(descendants); - } - } - - /** - * Sets a hint for which fragment is our parent which allows the fragment to return correct - * information about its parents before pending fragment transactions have been executed. - */ - void setParentFragmentHint(@Nullable Fragment parentFragmentHint) { - this.parentFragmentHint = parentFragmentHint; - if (parentFragmentHint != null && parentFragmentHint.getActivity() != null) { - registerFragmentWithRoot(parentFragmentHint.getActivity()); - } - } - - @Nullable - @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) - private Fragment getParentFragmentUsingHint() { - final Fragment fragment; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { - fragment = getParentFragment(); - } else { - fragment = null; - } - return fragment != null ? fragment : parentFragmentHint; - } - - /** Returns true if the fragment is a descendant of our parent. */ - @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) - private boolean isDescendant(@NonNull Fragment fragment) { - Fragment root = getParentFragment(); - Fragment parentFragment; - while ((parentFragment = fragment.getParentFragment()) != null) { - if (parentFragment.equals(root)) { - return true; - } - fragment = fragment.getParentFragment(); - } - return false; - } - - @SuppressWarnings("deprecation") - private void registerFragmentWithRoot(@NonNull Activity activity) { - unregisterFragmentWithRoot(); - rootRequestManagerFragment = - Glide.get(activity).getRequestManagerRetriever().getRequestManagerFragment(activity); - if (!equals(rootRequestManagerFragment)) { - rootRequestManagerFragment.addChildRequestManagerFragment(this); - } - } - - private void unregisterFragmentWithRoot() { - if (rootRequestManagerFragment != null) { - rootRequestManagerFragment.removeChildRequestManagerFragment(this); - rootRequestManagerFragment = null; - } - } - - @SuppressWarnings("deprecation") - @Override - public void onAttach(Activity activity) { - super.onAttach(activity); - try { - registerFragmentWithRoot(activity); - } catch (IllegalStateException e) { - // OnAttach can be called after the activity is destroyed, see #497. - if (Log.isLoggable(TAG, Log.WARN)) { - Log.w(TAG, "Unable to register fragment with root", e); - } - } - } - - @Override - public void onDetach() { - super.onDetach(); - unregisterFragmentWithRoot(); - } - - @Override - public void onStart() { - super.onStart(); - lifecycle.onStart(); - } - - @Override - public void onStop() { - super.onStop(); - lifecycle.onStop(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - lifecycle.onDestroy(); - unregisterFragmentWithRoot(); - } - - @Override - public String toString() { - return super.toString() + "{parent=" + getParentFragmentUsingHint() + "}"; - } - - private class FragmentRequestManagerTreeNode implements RequestManagerTreeNode { - - @Synthetic - FragmentRequestManagerTreeNode() {} - - @SuppressWarnings("deprecation") - @NonNull - @Override - public Set getDescendants() { - Set descendantFragments = getDescendantRequestManagerFragments(); - Set descendants = new HashSet<>(descendantFragments.size()); - for (RequestManagerFragment fragment : descendantFragments) { - if (fragment.getRequestManager() != null) { - descendants.add(fragment.getRequestManager()); - } + public RequestManagerTreeNode getRequestManagerTreeNode() { + return new RequestManagerTreeNode() { + @NonNull + @Override + public Set getDescendants() { + return Collections.emptySet(); } - return descendants; - } - - @SuppressWarnings("deprecation") - @Override - public String toString() { - return super.toString() + "{fragment=" + RequestManagerFragment.this + "}"; - } + }; } } diff --git a/library/src/main/java/com/bumptech/glide/manager/RequestManagerRetriever.java b/library/src/main/java/com/bumptech/glide/manager/RequestManagerRetriever.java index 53136d8f7a..284705b6e9 100644 --- a/library/src/main/java/com/bumptech/glide/manager/RequestManagerRetriever.java +++ b/library/src/main/java/com/bumptech/glide/manager/RequestManagerRetriever.java @@ -6,13 +6,8 @@ import android.content.Context; import android.content.ContextWrapper; import android.os.Build; -import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; -import android.os.Bundle; import android.os.Handler; -import android.os.Looper; import android.os.Message; -import android.util.Log; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -22,14 +17,11 @@ import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; import com.bumptech.glide.Glide; -import com.bumptech.glide.GlideBuilder.WaitForFramesAfterTrimMemory; -import com.bumptech.glide.GlideExperiments; import com.bumptech.glide.RequestManager; import com.bumptech.glide.load.resource.bitmap.HardwareConfigState; import com.bumptech.glide.util.Preconditions; import com.bumptech.glide.util.Util; import java.util.Collection; -import java.util.HashMap; import java.util.Map; /** @@ -38,59 +30,32 @@ */ public class RequestManagerRetriever implements Handler.Callback { @VisibleForTesting static final String FRAGMENT_TAG = "com.bumptech.glide.manager"; - private static final String TAG = "RMRetriever"; - - private static final int ID_REMOVE_FRAGMENT_MANAGER = 1; - private static final int ID_REMOVE_SUPPORT_FRAGMENT_MANAGER = 2; - - // Hacks based on the implementation of FragmentManagerImpl in the non-support libraries that - // allow us to iterate over and retrieve all active Fragments in a FragmentManager. - private static final String FRAGMENT_INDEX_KEY = "key"; /** The top application level RequestManager. */ private volatile RequestManager applicationManager; - /** Pending adds for RequestManagerFragments. */ - @SuppressWarnings("deprecation") - @VisibleForTesting - final Map pendingRequestManagerFragments = - new HashMap<>(); - - /** Pending adds for SupportRequestManagerFragments. */ - @VisibleForTesting - final Map pendingSupportRequestManagerFragments = - new HashMap<>(); - - /** Main thread handler to handle cleaning up pending fragment maps. */ - private final Handler handler; - private final RequestManagerFactory factory; // Objects used to find Fragments and Activities containing views. private final ArrayMap tempViewToSupportFragment = new ArrayMap<>(); - private final ArrayMap tempViewToFragment = new ArrayMap<>(); - private final Bundle tempBundle = new Bundle(); // This is really misplaced here, but to put it anywhere else means duplicating all of the // Fragment/Activity extraction logic that already exists here. It's gross, but less likely to // break. private final FrameWaiter frameWaiter; + private final LifecycleRequestManagerRetriever lifecycleRequestManagerRetriever; - public RequestManagerRetriever( - @Nullable RequestManagerFactory factory, GlideExperiments experiments) { + public RequestManagerRetriever(@Nullable RequestManagerFactory factory) { this.factory = factory != null ? factory : DEFAULT_FACTORY; - handler = new Handler(Looper.getMainLooper(), this /* Callback */); - - frameWaiter = buildFrameWaiter(experiments); + lifecycleRequestManagerRetriever = new LifecycleRequestManagerRetriever(this.factory); + frameWaiter = buildFrameWaiter(); } - private static FrameWaiter buildFrameWaiter(GlideExperiments experiments) { + private static FrameWaiter buildFrameWaiter() { if (!HardwareConfigState.HARDWARE_BITMAPS_SUPPORTED || !HardwareConfigState.BLOCK_HARDWARE_BITMAPS_WHEN_GL_CONTEXT_MIGHT_NOT_BE_INITIALIZED) { return new DoNothingFirstFrameWaiter(); } - return experiments.isEnabled(WaitForFramesAfterTrimMemory.class) - ? new FirstFrameAndAfterTrimMemoryWaiter() - : new FirstFrameWaiter(); + return new FirstFrameWaiter(); } @NonNull @@ -126,8 +91,6 @@ public RequestManager get(@NonNull Context context) { } else if (Util.isOnMainThread() && !(context instanceof Application)) { if (context instanceof FragmentActivity) { return get((FragmentActivity) context); - } else if (context instanceof Activity) { - return get((Activity) context); } else if (context instanceof ContextWrapper // Only unwrap a ContextWrapper if the baseContext has a non-null application context. // Context#createPackageContext may return a Context without an Application instance, @@ -144,12 +107,17 @@ public RequestManager get(@NonNull Context context) { public RequestManager get(@NonNull FragmentActivity activity) { if (Util.isOnBackgroundThread()) { return get(activity.getApplicationContext()); - } else { - assertNotDestroyed(activity); - frameWaiter.registerSelf(activity); - FragmentManager fm = activity.getSupportFragmentManager(); - return supportFragmentGet(activity, fm, /*parentHint=*/ null, isActivityVisible(activity)); } + assertNotDestroyed(activity); + frameWaiter.registerSelf(activity); + boolean isActivityVisible = isActivityVisible(activity); + Glide glide = Glide.get(activity.getApplicationContext()); + return lifecycleRequestManagerRetriever.getOrCreate( + activity, + glide, + activity.getLifecycle(), + activity.getSupportFragmentManager(), + isActivityVisible); } @NonNull @@ -159,35 +127,32 @@ public RequestManager get(@NonNull Fragment fragment) { "You cannot start a load on a fragment before it is attached or after it is destroyed"); if (Util.isOnBackgroundThread()) { return get(fragment.getContext().getApplicationContext()); - } else { - // In some unusual cases, it's possible to have a Fragment not hosted by an activity. There's - // not all that much we can do here. Most apps will be started with a standard activity. If - // we manage not to register the first frame waiter for a while, the consequences are not - // catastrophic, we'll just use some extra memory. - if (fragment.getActivity() != null) { - frameWaiter.registerSelf(fragment.getActivity()); - } - FragmentManager fm = fragment.getChildFragmentManager(); - return supportFragmentGet(fragment.getContext(), fm, fragment, fragment.isVisible()); } + // In some unusual cases, it's possible to have a Fragment not hosted by an activity. There's + // not all that much we can do here. Most apps will be started with a standard activity. If + // we manage not to register the first frame waiter for a while, the consequences are not + // catastrophic, we'll just use some extra memory. + if (fragment.getActivity() != null) { + frameWaiter.registerSelf(fragment.getActivity()); + } + FragmentManager fm = fragment.getChildFragmentManager(); + Context context = fragment.getContext(); + Glide glide = Glide.get(context.getApplicationContext()); + return lifecycleRequestManagerRetriever.getOrCreate( + context, glide, fragment.getLifecycle(), fm, fragment.isVisible()); } - @SuppressWarnings("deprecation") + /** + * @deprecated This is identical to calling {@link #get(Context)} with the application context. + * Use androidx Activities instead (ie {@link FragmentActivity}, or {@link + * androidx.appcompat.app.AppCompatActivity}). + */ + @Deprecated @NonNull public RequestManager get(@NonNull Activity activity) { - if (Util.isOnBackgroundThread()) { - return get(activity.getApplicationContext()); - } else if (activity instanceof FragmentActivity) { - return get((FragmentActivity) activity); - } else { - assertNotDestroyed(activity); - frameWaiter.registerSelf(activity); - android.app.FragmentManager fm = activity.getFragmentManager(); - return fragmentGet(activity, fm, /*parentHint=*/ null, isActivityVisible(activity)); - } + return get(activity.getApplicationContext()); } - @SuppressWarnings("deprecation") @NonNull public RequestManager get(@NonNull View view) { if (Util.isOnBackgroundThread()) { @@ -213,11 +178,7 @@ public RequestManager get(@NonNull View view) { } // Standard Fragments. - android.app.Fragment fragment = findFragment(view, activity); - if (fragment == null) { - return get(activity); - } - return get(fragment); + return get(view.getContext().getApplicationContext()); } private static void findAllSupportFragmentsWithViews( @@ -259,78 +220,6 @@ private Fragment findSupportFragment(@NonNull View target, @NonNull FragmentActi return result; } - @SuppressWarnings({"deprecation", "DeprecatedIsStillUsed"}) - @Deprecated - @Nullable - private android.app.Fragment findFragment(@NonNull View target, @NonNull Activity activity) { - tempViewToFragment.clear(); - findAllFragmentsWithViews(activity.getFragmentManager(), tempViewToFragment); - - android.app.Fragment result = null; - - View activityRoot = activity.findViewById(android.R.id.content); - View current = target; - while (!current.equals(activityRoot)) { - result = tempViewToFragment.get(current); - if (result != null) { - break; - } - if (current.getParent() instanceof View) { - current = (View) current.getParent(); - } else { - break; - } - } - tempViewToFragment.clear(); - return result; - } - - // TODO: Consider using an accessor class in the support library package to more directly retrieve - // non-support Fragments. - @SuppressWarnings("deprecation") - @Deprecated - @TargetApi(Build.VERSION_CODES.O) - private void findAllFragmentsWithViews( - @NonNull android.app.FragmentManager fragmentManager, - @NonNull ArrayMap result) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - for (android.app.Fragment fragment : fragmentManager.getFragments()) { - if (fragment.getView() != null) { - result.put(fragment.getView(), fragment); - findAllFragmentsWithViews(fragment.getChildFragmentManager(), result); - } - } - } else { - findAllFragmentsWithViewsPreO(fragmentManager, result); - } - } - - @SuppressWarnings("deprecation") - @Deprecated - private void findAllFragmentsWithViewsPreO( - @NonNull android.app.FragmentManager fragmentManager, - @NonNull ArrayMap result) { - int index = 0; - while (true) { - tempBundle.putInt(FRAGMENT_INDEX_KEY, index++); - android.app.Fragment fragment = null; - try { - fragment = fragmentManager.getFragment(tempBundle, FRAGMENT_INDEX_KEY); - } catch (Exception e) { - // This generates log spam from FragmentManager anyway. - } - if (fragment == null) { - break; - } - if (fragment.getView() != null) { - result.put(fragment.getView(), fragment); - if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) { - findAllFragmentsWithViews(fragment.getChildFragmentManager(), result); - } - } - } - } - @Nullable private static Activity findActivity(@NonNull Context context) { if (context instanceof Activity) { @@ -349,7 +238,10 @@ private static void assertNotDestroyed(@NonNull Activity activity) { } } - @SuppressWarnings("deprecation") + /** + * @deprecated This is equivalent to calling {@link #get(Context)} with the application context. + * Use androidx fragments instead: {@link Fragment}. + */ @Deprecated @NonNull @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) @@ -358,76 +250,7 @@ public RequestManager get(@NonNull android.app.Fragment fragment) { throw new IllegalArgumentException( "You cannot start a load on a fragment before it is attached"); } - if (Util.isOnBackgroundThread() || Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { - return get(fragment.getActivity().getApplicationContext()); - } else { - // In some unusual cases, it's possible to have a Fragment not hosted by an activity. There's - // not all that much we can do here. Most apps will be started with a standard activity. If - // we manage not to register the first frame waiter for a while, the consequences are not - // catastrophic, we'll just use some extra memory. - if (fragment.getActivity() != null) { - frameWaiter.registerSelf(fragment.getActivity()); - } - android.app.FragmentManager fm = fragment.getChildFragmentManager(); - return fragmentGet(fragment.getActivity(), fm, fragment, fragment.isVisible()); - } - } - - @SuppressWarnings("deprecation") - @Deprecated - @NonNull - RequestManagerFragment getRequestManagerFragment(Activity activity) { - return getRequestManagerFragment(activity.getFragmentManager(), /*parentHint=*/ null); - } - - @SuppressWarnings("deprecation") - @NonNull - private RequestManagerFragment getRequestManagerFragment( - @NonNull final android.app.FragmentManager fm, @Nullable android.app.Fragment parentHint) { - RequestManagerFragment current = (RequestManagerFragment) fm.findFragmentByTag(FRAGMENT_TAG); - if (current == null) { - current = pendingRequestManagerFragments.get(fm); - if (current == null) { - current = new RequestManagerFragment(); - current.setParentFragmentHint(parentHint); - pendingRequestManagerFragments.put(fm, current); - fm.beginTransaction().add(current, FRAGMENT_TAG).commitAllowingStateLoss(); - handler.obtainMessage(ID_REMOVE_FRAGMENT_MANAGER, fm).sendToTarget(); - } - } - return current; - } - - @SuppressWarnings({"deprecation", "DeprecatedIsStillUsed"}) - @Deprecated - @NonNull - private RequestManager fragmentGet( - @NonNull Context context, - @NonNull android.app.FragmentManager fm, - @Nullable android.app.Fragment parentHint, - boolean isParentVisible) { - RequestManagerFragment current = getRequestManagerFragment(fm, parentHint); - RequestManager requestManager = current.getRequestManager(); - if (requestManager == null) { - // TODO(b/27524013): Factor out this Glide.get() call. - Glide glide = Glide.get(context); - requestManager = - factory.build( - glide, current.getGlideLifecycle(), current.getRequestManagerTreeNode(), context); - // This is a bit of hack, we're going to start the RequestManager, but not the - // corresponding Lifecycle. It's safe to start the RequestManager, but starting the - // Lifecycle might trigger memory leaks. See b/154405040 - if (isParentVisible) { - requestManager.onStart(); - } - current.setRequestManager(requestManager); - } - return requestManager; - } - - @NonNull - SupportRequestManagerFragment getSupportRequestManagerFragment(FragmentManager fragmentManager) { - return getSupportRequestManagerFragment(fragmentManager, /*parentHint=*/ null); + return get(fragment.getActivity().getApplicationContext()); } private static boolean isActivityVisible(Context context) { @@ -437,73 +260,15 @@ private static boolean isActivityVisible(Context context) { return activity == null || !activity.isFinishing(); } - @NonNull - private SupportRequestManagerFragment getSupportRequestManagerFragment( - @NonNull final FragmentManager fm, @Nullable Fragment parentHint) { - SupportRequestManagerFragment current = - (SupportRequestManagerFragment) fm.findFragmentByTag(FRAGMENT_TAG); - if (current == null) { - current = pendingSupportRequestManagerFragments.get(fm); - if (current == null) { - current = new SupportRequestManagerFragment(); - current.setParentFragmentHint(parentHint); - pendingSupportRequestManagerFragments.put(fm, current); - fm.beginTransaction().add(current, FRAGMENT_TAG).commitAllowingStateLoss(); - handler.obtainMessage(ID_REMOVE_SUPPORT_FRAGMENT_MANAGER, fm).sendToTarget(); - } - } - return current; - } - - @NonNull - private RequestManager supportFragmentGet( - @NonNull Context context, - @NonNull FragmentManager fm, - @Nullable Fragment parentHint, - boolean isParentVisible) { - SupportRequestManagerFragment current = getSupportRequestManagerFragment(fm, parentHint); - RequestManager requestManager = current.getRequestManager(); - if (requestManager == null) { - // TODO(b/27524013): Factor out this Glide.get() call. - Glide glide = Glide.get(context); - requestManager = - factory.build( - glide, current.getGlideLifecycle(), current.getRequestManagerTreeNode(), context); - // This is a bit of hack, we're going to start the RequestManager, but not the - // corresponding Lifecycle. It's safe to start the RequestManager, but starting the - // Lifecycle might trigger memory leaks. See b/154405040 - if (isParentVisible) { - requestManager.onStart(); - } - current.setRequestManager(requestManager); - } - return requestManager; - } - + /** + * @deprecated This method is no longer called by Glide or provides any functionality and it will + * be removed in the future. Retained for now to preserve backwards compatibility. + */ + @Deprecated + @SuppressWarnings("PMD.CollapsibleIfStatements") @Override public boolean handleMessage(Message message) { - boolean handled = true; - Object removed = null; - Object key = null; - switch (message.what) { - case ID_REMOVE_FRAGMENT_MANAGER: - android.app.FragmentManager fm = (android.app.FragmentManager) message.obj; - key = fm; - removed = pendingRequestManagerFragments.remove(fm); - break; - case ID_REMOVE_SUPPORT_FRAGMENT_MANAGER: - FragmentManager supportFm = (FragmentManager) message.obj; - key = supportFm; - removed = pendingSupportRequestManagerFragments.remove(supportFm); - break; - default: - handled = false; - break; - } - if (handled && removed == null && Log.isLoggable(TAG, Log.WARN)) { - Log.w(TAG, "Failed to remove expected request manager fragment, manager: " + key); - } - return handled; + return false; } /** Used internally to create {@link RequestManager}s. */ diff --git a/library/src/main/java/com/bumptech/glide/manager/RequestTracker.java b/library/src/main/java/com/bumptech/glide/manager/RequestTracker.java index 96d536da74..7f51edfb49 100644 --- a/library/src/main/java/com/bumptech/glide/manager/RequestTracker.java +++ b/library/src/main/java/com/bumptech/glide/manager/RequestTracker.java @@ -95,6 +95,8 @@ public void pauseRequests() { public void pauseAllRequests() { isPaused = true; for (Request request : Util.getSnapshot(requests)) { + // TODO(judds): Failed requests return false from isComplete(). They're still restarted in + // resumeRequests, but they're not cleared here. We should probably clear all requests here? if (request.isRunning() || request.isComplete()) { request.clear(); pendingRequests.add(request); diff --git a/library/src/main/java/com/bumptech/glide/manager/SingletonConnectivityReceiver.java b/library/src/main/java/com/bumptech/glide/manager/SingletonConnectivityReceiver.java new file mode 100644 index 0000000000..26b37989d0 --- /dev/null +++ b/library/src/main/java/com/bumptech/glide/manager/SingletonConnectivityReceiver.java @@ -0,0 +1,367 @@ +package com.bumptech.glide.manager; + +import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.net.ConnectivityManager.NetworkCallback; +import android.net.Network; +import android.net.NetworkInfo; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Build.VERSION_CODES; +import android.util.Log; +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; +import com.bumptech.glide.manager.ConnectivityMonitor.ConnectivityListener; +import com.bumptech.glide.util.GlideSuppliers; +import com.bumptech.glide.util.GlideSuppliers.GlideSupplier; +import com.bumptech.glide.util.Synthetic; +import com.bumptech.glide.util.Util; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executor; + +/** Uses {@link android.net.ConnectivityManager} to identify connectivity changes. */ +final class SingletonConnectivityReceiver { + private static volatile SingletonConnectivityReceiver instance; + private static final String TAG = "ConnectivityMonitor"; + + private final FrameworkConnectivityMonitor frameworkConnectivityMonitor; + + @GuardedBy("this") + @Synthetic + final Set listeners = new HashSet(); + + @GuardedBy("this") + private boolean isRegistered; + + static SingletonConnectivityReceiver get(@NonNull Context context) { + if (instance == null) { + synchronized (SingletonConnectivityReceiver.class) { + if (instance == null) { + instance = new SingletonConnectivityReceiver(context.getApplicationContext()); + } + } + } + return instance; + } + + @VisibleForTesting + static void reset() { + instance = null; + } + + private SingletonConnectivityReceiver(final @NonNull Context context) { + GlideSupplier connectivityManager = + GlideSuppliers.memorize( + new GlideSupplier() { + @Override + public ConnectivityManager get() { + return (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + } + }); + ConnectivityListener connectivityListener = + new ConnectivityListener() { + @Override + public void onConnectivityChanged(boolean isConnected) { + Util.assertMainThread(); + List toNotify; + synchronized (SingletonConnectivityReceiver.this) { + toNotify = new ArrayList<>(listeners); + } + for (ConnectivityListener listener : toNotify) { + listener.onConnectivityChanged(isConnected); + } + } + }; + + frameworkConnectivityMonitor = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + ? new FrameworkConnectivityMonitorPostApi24(connectivityManager, connectivityListener) + : new FrameworkConnectivityMonitorPreApi24( + context, connectivityManager, connectivityListener); + } + + synchronized void register(ConnectivityListener listener) { + listeners.add(listener); + maybeRegisterReceiver(); + } + + /** + * To avoid holding a lock while notifying listeners, the unregistered listener may still be + * notified about a connectivity change after this method completes if this method is called on a + * thread other than the main thread and if a connectivity broadcast is racing with this method. + * Callers must handle this case. + */ + synchronized void unregister(ConnectivityListener listener) { + listeners.remove(listener); + maybeUnregisterReceiver(); + } + + @GuardedBy("this") + private void maybeRegisterReceiver() { + if (isRegistered || listeners.isEmpty()) { + return; + } + isRegistered = frameworkConnectivityMonitor.register(); + } + + @GuardedBy("this") + private void maybeUnregisterReceiver() { + if (!isRegistered || !listeners.isEmpty()) { + return; + } + + frameworkConnectivityMonitor.unregister(); + isRegistered = false; + } + + private interface FrameworkConnectivityMonitor { + boolean register(); + + void unregister(); + } + + @RequiresApi(VERSION_CODES.N) + private static final class FrameworkConnectivityMonitorPostApi24 + implements FrameworkConnectivityMonitor { + + @Synthetic boolean isConnected; + @Synthetic final ConnectivityListener listener; + private final GlideSupplier connectivityManager; + private final NetworkCallback networkCallback = + new NetworkCallback() { + @Override + public void onAvailable(@NonNull Network network) { + postOnConnectivityChange(true); + } + + @Override + public void onLost(@NonNull Network network) { + postOnConnectivityChange(false); + } + + private void postOnConnectivityChange(final boolean newState) { + // We could use registerDefaultNetworkCallback with a Handler, but that's only available + // on API 26, instead of API 24. We can mimic the same behavior here manually by + // posting to the UI thread. All calls have to be posted to make sure that we retain the + // original order. Otherwise a call on a background thread, followed by a call on the UI + // thread could result in the first call running second. + Util.postOnUiThread( + new Runnable() { + @Override + public void run() { + onConnectivityChange(newState); + } + }); + } + + @Synthetic + void onConnectivityChange(boolean newState) { + // See b/201425456. + Util.assertMainThread(); + + boolean wasConnected = isConnected; + isConnected = newState; + if (wasConnected != newState) { + listener.onConnectivityChanged(newState); + } + } + }; + + FrameworkConnectivityMonitorPostApi24( + GlideSupplier connectivityManager, ConnectivityListener listener) { + this.connectivityManager = connectivityManager; + this.listener = listener; + } + + // Permissions are checked in the factory instead. + @SuppressLint("MissingPermission") + @Override + public boolean register() { + isConnected = connectivityManager.get().getActiveNetwork() != null; + try { + connectivityManager.get().registerDefaultNetworkCallback(networkCallback); + return true; + // See b/201664814, b/204226444: At least TooManyRequestsException is not public and + // doesn't extend from any subclass :/. + } catch (RuntimeException e) { + if (Log.isLoggable(TAG, Log.WARN)) { + Log.w(TAG, "Failed to register callback", e); + } + return false; + } + } + + @Override + public void unregister() { + connectivityManager.get().unregisterNetworkCallback(networkCallback); + } + } + + /** + * All interactions with connectivity manager and registering/unregistering broadcast receivers + * are punted to a background thread. We use serial execution to make sure that they still happen + * in the correct order. The system calls required to register/unregister the receiver and to + * determine connectivity status are expensive to run on the main thread. isConnected and + * isRegistered should only be used on the serial background thread. Listeners should only be + * notified on the main thread. Because of the delays caused by punting threads, listeners may be + * notified with the incorrect state. Howeve the strict ordering means that they will shortly + * after be notified with the correct state. + */ + private static final class FrameworkConnectivityMonitorPreApi24 + implements FrameworkConnectivityMonitor { + // Using AsyncTasks's executor is a hack. We need a background thread, but it's not trivial to + // pass one through to this point. We could make a breaking API change, which upsets external + // users. Or we could try to add an API to expose one of Glide's executors via the singleton, + // but that could allow Glide's executors to be misused as general purpose executors. Given that + // this code is deprecated anyway, using some pre-existing general purpose executor doesn't seem + // wildly unreasonable. + @Synthetic static final Executor EXECUTOR = AsyncTask.SERIAL_EXECUTOR; + @Synthetic final Context context; + @Synthetic final ConnectivityListener listener; + private final GlideSupplier connectivityManager; + // These are only manipulated serially, but the executor might use separate threads to do so, + // so we use volatile. + @Synthetic volatile boolean isConnected; + @Synthetic volatile boolean isRegistered; + + @Synthetic + final BroadcastReceiver connectivityReceiver = + new BroadcastReceiver() { + @Override + public void onReceive(@NonNull Context context, Intent intent) { + onConnectivityChange(); + } + }; + + FrameworkConnectivityMonitorPreApi24( + Context context, + GlideSupplier connectivityManager, + ConnectivityListener listener) { + this.context = context.getApplicationContext(); + this.connectivityManager = connectivityManager; + this.listener = listener; + } + + @Override + public boolean register() { + EXECUTOR.execute( + new Runnable() { + @Override + public void run() { + // Initialize isConnected so that we notice the first time around when there's a + // broadcast. + // TODO(judds): This causes a race where: + // 1. Connectivity is disconnected + // 2. Register is called, but punted to a background thread and not run yet + // 3. Some network requiring request is started, runs and fails due to connectivity + // 4. Connectivity is re-established + // 5. This code finally runs on the background thread. + // In step 5, we'll think that we're currently connected and won't trigger a retry for + // any previously failed requests. + // In the long run it might be nice to define some explicit initialization step for + // Glide where we do this and other expensive things on a background thread prior to + // starting the first request. For now it seems better to just accept this race than + // either take the latency hit of the IPC on the main thread, or try something like + // always notifying all listeners once right after this logic runs just in case + // something failed. + isConnected = isConnected(); + try { + // See #1405 + context.registerReceiver( + connectivityReceiver, + new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + isRegistered = true; + } catch (SecurityException e) { + // See #1417, registering the receiver can throw SecurityException. + if (Log.isLoggable(TAG, Log.WARN)) { + Log.w(TAG, "Failed to register", e); + } + isRegistered = false; + } + } + }); + + // We track our registration status internally, so we always need to be called to unregister. + return true; + } + + @Override + public void unregister() { + // Always post to the executor to make sure everything runs in the correct order. If we short + // circuit that by checking isConnected on this thread, we might leak a receiver. + EXECUTOR.execute( + new Runnable() { + @Override + public void run() { + if (!isRegistered) { + return; + } + isRegistered = false; + context.unregisterReceiver(connectivityReceiver); + } + }); + } + + @Synthetic + void onConnectivityChange() { + EXECUTOR.execute( + new Runnable() { + @Override + public void run() { + boolean wasConnected = isConnected; + isConnected = isConnected(); + if (wasConnected != isConnected) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "connectivity changed, isConnected: " + isConnected); + } + + notifyChangeOnUiThread(isConnected); + } + } + }); + } + + // Pass through the boolean because the instance variable could change while we're waiting for + // the runnable to be executed on the main thread. + @Synthetic + void notifyChangeOnUiThread(final boolean isConnected) { + Util.postOnUiThread( + new Runnable() { + @Override + public void run() { + listener.onConnectivityChanged(isConnected); + } + }); + } + + @SuppressWarnings("WeakerAccess") + @Synthetic + // Permissions are checked in the factory instead. + @SuppressLint("MissingPermission") + boolean isConnected() { + NetworkInfo networkInfo; + try { + networkInfo = connectivityManager.get().getActiveNetworkInfo(); + } catch (RuntimeException e) { + // #1405 shows that this throws a SecurityException. + // b/70869360 shows that this throws NullPointerException on APIs 22, 23, and 24. + // b/70869360 also shows that this throws RuntimeException on API 24 and 25. + if (Log.isLoggable(TAG, Log.WARN)) { + Log.w(TAG, "Failed to determine connectivity status when connectivity changed", e); + } + // Default to true; + return true; + } + return networkInfo != null && networkInfo.isConnected(); + } + } +} diff --git a/library/src/main/java/com/bumptech/glide/manager/SupportRequestManagerFragment.java b/library/src/main/java/com/bumptech/glide/manager/SupportRequestManagerFragment.java index fe558d4d02..37eb165c86 100644 --- a/library/src/main/java/com/bumptech/glide/manager/SupportRequestManagerFragment.java +++ b/library/src/main/java/com/bumptech/glide/manager/SupportRequestManagerFragment.java @@ -1,248 +1,45 @@ package com.bumptech.glide.manager; -import android.annotation.SuppressLint; -import android.content.Context; -import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import com.bumptech.glide.Glide; import com.bumptech.glide.RequestManager; -import com.bumptech.glide.util.Synthetic; -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; /** * A view-less {@link androidx.fragment.app.Fragment} used to safely store an {@link * com.bumptech.glide.RequestManager} that can be used to start, stop and manage Glide requests * started for targets within the fragment or activity this fragment is a child of. * - * @see com.bumptech.glide.manager.RequestManagerFragment * @see com.bumptech.glide.manager.RequestManagerRetriever * @see com.bumptech.glide.RequestManager + * @deprecated This class is unused by Glide. All functionality has been removed. The class will be + * removed in a future version. */ +@Deprecated public class SupportRequestManagerFragment extends Fragment { - private static final String TAG = "SupportRMFragment"; - private final ActivityFragmentLifecycle lifecycle; - private final RequestManagerTreeNode requestManagerTreeNode = - new SupportFragmentRequestManagerTreeNode(); - private final Set childRequestManagerFragments = new HashSet<>(); - - @Nullable private SupportRequestManagerFragment rootRequestManagerFragment; - @Nullable private RequestManager requestManager; - @Nullable private Fragment parentFragmentHint; - - public SupportRequestManagerFragment() { - this(new ActivityFragmentLifecycle()); - } - - @VisibleForTesting - @SuppressLint("ValidFragment") - public SupportRequestManagerFragment(@NonNull ActivityFragmentLifecycle lifecycle) { - this.lifecycle = lifecycle; - } /** - * Sets the current {@link com.bumptech.glide.RequestManager}. - * - * @param requestManager The manager to put. + * @deprecated A no-op method. See the class deprecation method for details. */ - public void setRequestManager(@Nullable RequestManager requestManager) { - this.requestManager = requestManager; - } - - @NonNull - ActivityFragmentLifecycle getGlideLifecycle() { - return lifecycle; - } + @Deprecated + public void setRequestManager(@Nullable RequestManager requestManager) {} - /** Returns the current {@link com.bumptech.glide.RequestManager} or null if none is put. */ + /** + * @deprecated Always returns {@code null}. See the class deprecation method for details. + */ @Nullable + @Deprecated public RequestManager getRequestManager() { - return requestManager; + return null; } /** - * Returns the {@link RequestManagerTreeNode} that provides tree traversal methods relative to the - * associated {@link RequestManager}. + * @deprecated Always returns {@link EmptyRequestManagerTreeNode}. See the class deprecation + * method for details. */ + @Deprecated @NonNull public RequestManagerTreeNode getRequestManagerTreeNode() { - return requestManagerTreeNode; - } - - private void addChildRequestManagerFragment(SupportRequestManagerFragment child) { - childRequestManagerFragments.add(child); - } - - private void removeChildRequestManagerFragment(SupportRequestManagerFragment child) { - childRequestManagerFragments.remove(child); - } - - /** - * Returns the set of fragments that this RequestManagerFragment's parent is a parent to. (i.e. - * our parent is the fragment that we are annotating). - */ - @Synthetic - @NonNull - Set getDescendantRequestManagerFragments() { - if (rootRequestManagerFragment == null) { - return Collections.emptySet(); - } else if (equals(rootRequestManagerFragment)) { - return Collections.unmodifiableSet(childRequestManagerFragments); - } else { - Set descendants = new HashSet<>(); - for (SupportRequestManagerFragment fragment : - rootRequestManagerFragment.getDescendantRequestManagerFragments()) { - if (isDescendant(fragment.getParentFragmentUsingHint())) { - descendants.add(fragment); - } - } - return Collections.unmodifiableSet(descendants); - } - } - - /** - * Sets a hint for which fragment is our parent which allows the fragment to return correct - * information about its parents before pending fragment transactions have been executed. - */ - void setParentFragmentHint(@Nullable Fragment parentFragmentHint) { - this.parentFragmentHint = parentFragmentHint; - if (parentFragmentHint == null || parentFragmentHint.getContext() == null) { - return; - } - FragmentManager rootFragmentManager = getRootFragmentManager(parentFragmentHint); - if (rootFragmentManager == null) { - return; - } - registerFragmentWithRoot(parentFragmentHint.getContext(), rootFragmentManager); - } - - @Nullable - private static FragmentManager getRootFragmentManager(@NonNull Fragment fragment) { - while (fragment.getParentFragment() != null) { - fragment = fragment.getParentFragment(); - } - return fragment.getFragmentManager(); - } - - @Nullable - private Fragment getParentFragmentUsingHint() { - Fragment fragment = getParentFragment(); - return fragment != null ? fragment : parentFragmentHint; - } - - /** Returns true if the fragment is a descendant of our parent. */ - private boolean isDescendant(@NonNull Fragment fragment) { - Fragment root = getParentFragmentUsingHint(); - Fragment parentFragment; - while ((parentFragment = fragment.getParentFragment()) != null) { - if (parentFragment.equals(root)) { - return true; - } - fragment = fragment.getParentFragment(); - } - return false; - } - - private void registerFragmentWithRoot( - @NonNull Context context, @NonNull FragmentManager fragmentManager) { - unregisterFragmentWithRoot(); - rootRequestManagerFragment = - Glide.get(context) - .getRequestManagerRetriever() - .getSupportRequestManagerFragment(fragmentManager); - if (!equals(rootRequestManagerFragment)) { - rootRequestManagerFragment.addChildRequestManagerFragment(this); - } - } - - private void unregisterFragmentWithRoot() { - if (rootRequestManagerFragment != null) { - rootRequestManagerFragment.removeChildRequestManagerFragment(this); - rootRequestManagerFragment = null; - } - } - - @Override - public void onAttach(Context context) { - super.onAttach(context); - - FragmentManager rootFragmentManager = getRootFragmentManager(this); - if (rootFragmentManager == null) { - if (Log.isLoggable(TAG, Log.WARN)) { - // Not expected to occur; ancestor fragments should be attached before descendants. - Log.w(TAG, "Unable to register fragment with root, ancestor detached"); - } - return; - } - - try { - registerFragmentWithRoot(getContext(), rootFragmentManager); - } catch (IllegalStateException e) { - // OnAttach can be called after the activity is destroyed, see #497. - if (Log.isLoggable(TAG, Log.WARN)) { - Log.w(TAG, "Unable to register fragment with root", e); - } - } - } - - @Override - public void onDetach() { - super.onDetach(); - parentFragmentHint = null; - unregisterFragmentWithRoot(); - } - - @Override - public void onStart() { - super.onStart(); - lifecycle.onStart(); - } - - @Override - public void onStop() { - super.onStop(); - lifecycle.onStop(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - lifecycle.onDestroy(); - unregisterFragmentWithRoot(); - } - - @Override - public String toString() { - return super.toString() + "{parent=" + getParentFragmentUsingHint() + "}"; - } - - private class SupportFragmentRequestManagerTreeNode implements RequestManagerTreeNode { - - @Synthetic - SupportFragmentRequestManagerTreeNode() {} - - @NonNull - @Override - public Set getDescendants() { - Set descendantFragments = - getDescendantRequestManagerFragments(); - Set descendants = new HashSet<>(descendantFragments.size()); - for (SupportRequestManagerFragment fragment : descendantFragments) { - if (fragment.getRequestManager() != null) { - descendants.add(fragment.getRequestManager()); - } - } - return descendants; - } - - @Override - public String toString() { - return super.toString() + "{fragment=" + SupportRequestManagerFragment.this + "}"; - } + return new EmptyRequestManagerTreeNode(); } } diff --git a/library/src/main/java/com/bumptech/glide/module/ManifestParser.java b/library/src/main/java/com/bumptech/glide/module/ManifestParser.java index af42f709ea..35fce96e56 100644 --- a/library/src/main/java/com/bumptech/glide/module/ManifestParser.java +++ b/library/src/main/java/com/bumptech/glide/module/ManifestParser.java @@ -3,7 +3,9 @@ import android.content.Context; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; import android.util.Log; +import androidx.annotation.Nullable; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.List; @@ -24,6 +26,15 @@ public ManifestParser(Context context) { this.context = context; } + // getApplicationInfo returns null in Compose previews, see #4977 and b/263613353. + @SuppressWarnings("ConstantConditions") + @Nullable + private ApplicationInfo getOurApplicationInfo() throws NameNotFoundException { + return context + .getPackageManager() + .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + } + @SuppressWarnings("deprecation") public List parse() { if (Log.isLoggable(TAG, Log.DEBUG)) { @@ -31,11 +42,8 @@ public List parse() { } List modules = new ArrayList<>(); try { - ApplicationInfo appInfo = - context - .getPackageManager() - .getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); - if (appInfo.metaData == null) { + ApplicationInfo appInfo = getOurApplicationInfo(); + if (appInfo == null || appInfo.metaData == null) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Got null app info metadata"); } @@ -52,11 +60,13 @@ public List parse() { } } } + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Finished loading Glide modules"); + } } catch (PackageManager.NameNotFoundException e) { - throw new RuntimeException("Unable to find metadata to parse GlideModules", e); - } - if (Log.isLoggable(TAG, Log.DEBUG)) { - Log.d(TAG, "Finished loading Glide modules"); + if (Log.isLoggable(TAG, Log.ERROR)) { + Log.e(TAG, "Failed to parse glide modules", e); + } } return modules; diff --git a/library/src/main/java/com/bumptech/glide/provider/LoadPathCache.java b/library/src/main/java/com/bumptech/glide/provider/LoadPathCache.java index 36dec33fea..e030bfb3a4 100644 --- a/library/src/main/java/com/bumptech/glide/provider/LoadPathCache.java +++ b/library/src/main/java/com/bumptech/glide/provider/LoadPathCache.java @@ -27,8 +27,8 @@ public class LoadPathCache { Object.class, Collections.>emptyList(), new UnitTranscoder<>(), - /*listPool=*/ null)), - /*listPool=*/ null); + /* listPool= */ null)), + /* listPool= */ null); private final ArrayMap> cache = new ArrayMap<>(); private final AtomicReference keyRef = new AtomicReference<>(); diff --git a/library/src/main/java/com/bumptech/glide/request/BaseRequestOptions.java b/library/src/main/java/com/bumptech/glide/request/BaseRequestOptions.java index dd50ca1490..bd3390c7ef 100644 --- a/library/src/main/java/com/bumptech/glide/request/BaseRequestOptions.java +++ b/library/src/main/java/com/bumptech/glide/request/BaseRequestOptions.java @@ -28,6 +28,7 @@ import com.bumptech.glide.load.resource.bitmap.DrawableTransformation; import com.bumptech.glide.load.resource.bitmap.FitCenter; import com.bumptech.glide.load.resource.bitmap.VideoDecoder; +import com.bumptech.glide.load.resource.drawable.ResourceDrawableDecoder; import com.bumptech.glide.load.resource.gif.GifDrawable; import com.bumptech.glide.load.resource.gif.GifDrawableTransformation; import com.bumptech.glide.load.resource.gif.GifOptions; @@ -180,6 +181,15 @@ public T useAnimationPool(boolean flag) { /** * If set to true, will only load an item if found in the cache, and will not fetch from source. + * + *

By 'cache' we mean both the in memory cache and both types of disk cache ({@link + * DiskCacheStrategy#DATA} and {@link DiskCacheStrategy#RESOURCE}). If this flag is set to {@code + * true} and the item is not in the memory cache, but it is in one of the disk caches, the load + * will complete asynchronously. + * + *

If you'd like to only load an item from the memory cache. You can call this method with + * {@code true} and also call {@link #diskCacheStrategy(DiskCacheStrategy)} with {@link + * DiskCacheStrategy#NONE} */ @NonNull @CheckResult @@ -396,18 +406,11 @@ public T error(@DrawableRes int resourceId) { /** * Sets the {@link android.content.res.Resources.Theme} to apply when loading {@link Drawable}s - * for resource ids provided via {@link #error(int)}, {@link #placeholder(int)}, and {@link - * #fallback(Drawable)}. - * - *

The theme is NOT applied in the decoder that will attempt to decode a given - * resource id model on Glide's background threads. The theme is used exclusively on the main - * thread to obtain placeholder/error/fallback drawables to avoid leaking Activities. + * for resource ids, including those provided via {@link #error(int)}, {@link #placeholder(int)}, + * and {@link #fallback(Drawable)}. * - *

If the {@link android.content.Context} of the {@link android.app.Fragment} or {@link - * android.app.Activity} used to start this load has a different {@link - * android.content.res.Resources.Theme}, the {@link android.content.res.Resources.Theme} provided - * here will override the {@link android.content.res.Resources.Theme} of the {@link - * android.content.Context}. + *

The {@link android.content.res.Resources.Theme} provided here will override the {@link + * android.content.res.Resources.Theme} of the application {@link android.content.Context}. * * @param theme The theme to use when loading Drawables. * @return this request builder. @@ -418,11 +421,14 @@ public T theme(@Nullable Resources.Theme theme) { if (isAutoCloneEnabled) { return clone().theme(theme); } - this.theme = theme; - fields |= THEME; - - return selfOrThrowIfLocked(); + if (theme != null) { + fields |= THEME; + return set(ResourceDrawableDecoder.THEME, theme); + } else { + fields &= ~THEME; + return removeOption(ResourceDrawableDecoder.THEME); + } } /** @@ -555,6 +561,14 @@ public T set(@NonNull Option option, @NonNull Y value) { return selfOrThrowIfLocked(); } + T removeOption(@NonNull Option option) { + if (isAutoCloneEnabled) { + return clone().removeOption(option); + } + options.remove(option); + return selfOrThrowIfLocked(); + } + @NonNull @CheckResult public T decode(@NonNull Class resourceClass) { @@ -827,7 +841,7 @@ final T optionalTransform( } downsample(downsampleStrategy); - return transform(transformation, /*isRequired=*/ false); + return transform(transformation, /* isRequired= */ false); } // calling transform() on the result of clone() requires greater access. @@ -890,7 +904,7 @@ private T scaleOnlyTransform( @NonNull @CheckResult public T transform(@NonNull Transformation transformation) { - return transform(transformation, /*isRequired=*/ true); + return transform(transformation, /* isRequired= */ true); } /** @@ -911,7 +925,7 @@ public T transform(@NonNull Transformation transformation) { @CheckResult public T transform(@NonNull Transformation... transformations) { if (transformations.length > 1) { - return transform(new MultiTransformation<>(transformations), /*isRequired=*/ true); + return transform(new MultiTransformation<>(transformations), /* isRequired= */ true); } else if (transformations.length == 1) { return transform(transformations[0]); } else { @@ -938,7 +952,7 @@ public T transform(@NonNull Transformation... transformations) { @CheckResult @Deprecated public T transforms(@NonNull Transformation... transformations) { - return transform(new MultiTransformation<>(transformations), /*isRequired=*/ true); + return transform(new MultiTransformation<>(transformations), /* isRequired= */ true); } /** @@ -957,7 +971,7 @@ public T transforms(@NonNull Transformation... transformations) { @NonNull @CheckResult public T optionalTransform(@NonNull Transformation transformation) { - return transform(transformation, /*isRequired=*/ false); + return transform(transformation, /* isRequired= */ false); } @NonNull @@ -1000,7 +1014,7 @@ T transform(@NonNull Transformation transformation, boolean isRequired) @CheckResult public T optionalTransform( @NonNull Class resourceClass, @NonNull Transformation transformation) { - return transform(resourceClass, transformation, /*isRequired=*/ false); + return transform(resourceClass, transformation, /* isRequired= */ false); } @NonNull @@ -1044,7 +1058,7 @@ T transform( @CheckResult public T transform( @NonNull Class resourceClass, @NonNull Transformation transformation) { - return transform(resourceClass, transformation, /*isRequired=*/ true); + return transform(resourceClass, transformation, /* isRequired= */ true); } /** @@ -1195,31 +1209,43 @@ public T apply(@NonNull BaseRequestOptions o) { return selfOrThrowIfLocked(); } + /** + * Returns {@code true} if this {@link BaseRequestOptions} is equivalent to the given {@link + * BaseRequestOptions} (has all of the same options and sizes). + * + *

This method is identical to {@link #equals(Object)}, but this can not be overridden. We need + * to use this method instead of {@link #equals(Object)}, because child classes may have + * additional fields, such as listeners and models, that should not be considered when checking + * for equality. + */ + public final boolean isEquivalentTo(BaseRequestOptions other) { + return Float.compare(other.sizeMultiplier, sizeMultiplier) == 0 + && errorId == other.errorId + && Util.bothNullOrEqual(errorPlaceholder, other.errorPlaceholder) + && placeholderId == other.placeholderId + && Util.bothNullOrEqual(placeholderDrawable, other.placeholderDrawable) + && fallbackId == other.fallbackId + && Util.bothNullOrEqual(fallbackDrawable, other.fallbackDrawable) + && isCacheable == other.isCacheable + && overrideHeight == other.overrideHeight + && overrideWidth == other.overrideWidth + && isTransformationRequired == other.isTransformationRequired + && isTransformationAllowed == other.isTransformationAllowed + && useUnlimitedSourceGeneratorsPool == other.useUnlimitedSourceGeneratorsPool + && onlyRetrieveFromCache == other.onlyRetrieveFromCache + && diskCacheStrategy.equals(other.diskCacheStrategy) + && priority == other.priority + && options.equals(other.options) + && transformations.equals(other.transformations) + && resourceClass.equals(other.resourceClass) + && Util.bothNullOrEqual(signature, other.signature) + && Util.bothNullOrEqual(theme, other.theme); + } + @Override public boolean equals(Object o) { if (o instanceof BaseRequestOptions) { - BaseRequestOptions other = (BaseRequestOptions) o; - return Float.compare(other.sizeMultiplier, sizeMultiplier) == 0 - && errorId == other.errorId - && Util.bothNullOrEqual(errorPlaceholder, other.errorPlaceholder) - && placeholderId == other.placeholderId - && Util.bothNullOrEqual(placeholderDrawable, other.placeholderDrawable) - && fallbackId == other.fallbackId - && Util.bothNullOrEqual(fallbackDrawable, other.fallbackDrawable) - && isCacheable == other.isCacheable - && overrideHeight == other.overrideHeight - && overrideWidth == other.overrideWidth - && isTransformationRequired == other.isTransformationRequired - && isTransformationAllowed == other.isTransformationAllowed - && useUnlimitedSourceGeneratorsPool == other.useUnlimitedSourceGeneratorsPool - && onlyRetrieveFromCache == other.onlyRetrieveFromCache - && diskCacheStrategy.equals(other.diskCacheStrategy) - && priority == other.priority - && options.equals(other.options) - && transformations.equals(other.transformations) - && resourceClass.equals(other.resourceClass) - && Util.bothNullOrEqual(signature, other.signature) - && Util.bothNullOrEqual(theme, other.theme); + return isEquivalentTo((BaseRequestOptions) o); } return false; } diff --git a/library/src/main/java/com/bumptech/glide/request/ErrorRequestCoordinator.java b/library/src/main/java/com/bumptech/glide/request/ErrorRequestCoordinator.java index 4c112357f4..851acf2cdf 100644 --- a/library/src/main/java/com/bumptech/glide/request/ErrorRequestCoordinator.java +++ b/library/src/main/java/com/bumptech/glide/request/ErrorRequestCoordinator.java @@ -102,7 +102,9 @@ public boolean isEquivalentTo(Request o) { @Override public boolean canSetImage(Request request) { synchronized (requestLock) { - return parentCanSetImage() && isValidRequest(request); + // Only one of primary or error runs at a time, so if we've reached this point and nothing + // else is broken, we should have nothing else to enforce. + return parentCanSetImage(); } } @@ -114,14 +116,14 @@ private boolean parentCanSetImage() { @Override public boolean canNotifyStatusChanged(Request request) { synchronized (requestLock) { - return parentCanNotifyStatusChanged() && isValidRequest(request); + return parentCanNotifyStatusChanged() && isValidRequestForStatusChanged(request); } } @Override public boolean canNotifyCleared(Request request) { synchronized (requestLock) { - return parentCanNotifyCleared() && isValidRequest(request); + return parentCanNotifyCleared() && request.equals(primary); } } @@ -136,9 +138,17 @@ private boolean parentCanNotifyStatusChanged() { } @GuardedBy("requestLock") - private boolean isValidRequest(Request request) { - return request.equals(primary) - || (primaryState == RequestState.FAILED && request.equals(error)); + private boolean isValidRequestForStatusChanged(Request request) { + if (primaryState != RequestState.FAILED) { + return request.equals(primary); + } else { + return request.equals(error) + // We don't want to call onLoadStarted once for the primary request and then again + // if it fails and the error request starts. It's already running, so we might as well + // avoid the duplicate notification by only notifying about the error state when it's + // final. + && (errorState == RequestState.SUCCESS || errorState == RequestState.FAILED); + } } @Override diff --git a/library/src/main/java/com/bumptech/glide/request/ExperimentalRequestListener.java b/library/src/main/java/com/bumptech/glide/request/ExperimentalRequestListener.java index 5057664fea..476cd27812 100644 --- a/library/src/main/java/com/bumptech/glide/request/ExperimentalRequestListener.java +++ b/library/src/main/java/com/bumptech/glide/request/ExperimentalRequestListener.java @@ -16,6 +16,8 @@ @Deprecated public abstract class ExperimentalRequestListener implements RequestListener { + public void onRequestStarted(Object model) {} + /** * Identical to {@link #onResourceReady(Object, Object, Target, DataSource, boolean)} except that * {@code isAlternateCacheKey} is provided. diff --git a/library/src/main/java/com/bumptech/glide/request/RequestFutureTarget.java b/library/src/main/java/com/bumptech/glide/request/RequestFutureTarget.java index c8216f9df1..8d3e825199 100644 --- a/library/src/main/java/com/bumptech/glide/request/RequestFutureTarget.java +++ b/library/src/main/java/com/bumptech/glide/request/RequestFutureTarget.java @@ -258,6 +258,29 @@ public synchronized boolean onResourceReady( return false; } + @Override + public String toString() { + String toString = super.toString() + "[status="; + final String status; + Request pendingRequest = null; + synchronized (this) { + if (isCancelled) { + status = "CANCELLED"; + } else if (loadFailed) { + status = "FAILURE"; + } else if (resultReceived) { + status = "SUCCESS"; + } else { + status = "PENDING"; + pendingRequest = request; + } + } + if (pendingRequest != null) { + return toString + status + ", request=[" + pendingRequest + "]]"; + } + return toString + status + "]"; + } + @VisibleForTesting static class Waiter { // This is a simple wrapper class that is used to enable testing. The call to the wrapping class diff --git a/library/src/main/java/com/bumptech/glide/request/RequestListener.java b/library/src/main/java/com/bumptech/glide/request/RequestListener.java index bb3b48d465..4e88120373 100644 --- a/library/src/main/java/com/bumptech/glide/request/RequestListener.java +++ b/library/src/main/java/com/bumptech/glide/request/RequestListener.java @@ -7,6 +7,7 @@ import com.bumptech.glide.load.DataSource; import com.bumptech.glide.load.engine.GlideException; import com.bumptech.glide.request.target.Target; +import com.bumptech.glide.request.transition.Transition; /** * A class for monitoring the status of a request while images load. @@ -67,17 +68,21 @@ boolean onLoadFailed( * *

For threading guarantees, see the class comment. * - * @param resource The resource that was loaded for the target. - * @param model The specific model that was used to load the image. + * @param resource The resource that was loaded for the target. Non-null because a null resource + * will result in a call to {@link #onLoadFailed(GlideException, Object, Target, boolean)} + * instead of this method. + * @param model The specific model that was used to load the image. Non-null because a null model + * will result in a call to {@link #onLoadFailed(GlideException, Object, Target, boolean)} + * instead of this method. * @param target The target the model was loaded into. * @param dataSource The {@link DataSource} the resource was loaded from. * @param isFirstResource {@code true} if this is the first resource to in this load to be loaded * into the target. For example when loading a thumbnail and a full-sized image, this will be * {@code true} for the first image to load and {@code false} for the second. - * @return {@code true} to prevent {@link Target#onResourceReady(Drawable)} from being called on - * {@code target}, typically because the listener wants to update the {@code target} or the - * object the {@code target} wraps itself or {@code false} to allow {@link - * Target#onResourceReady(Drawable)} to be called on {@code target}. + * @return {@code true} to prevent {@link Target#onResourceReady(Object, Transition)} from being + * called on {@code target}, typically because the listener wants to update the {@code target} + * or the object the {@code target} wraps itself or {@code false} to allow {@link + * Target#onResourceReady(Object, Transition)} to be called on {@code target}. */ boolean onResourceReady( R resource, Object model, Target target, DataSource dataSource, boolean isFirstResource); diff --git a/library/src/main/java/com/bumptech/glide/request/RequestOptions.java b/library/src/main/java/com/bumptech/glide/request/RequestOptions.java index c2a1882a83..22dffcf3b3 100644 --- a/library/src/main/java/com/bumptech/glide/request/RequestOptions.java +++ b/library/src/main/java/com/bumptech/glide/request/RequestOptions.java @@ -279,4 +279,18 @@ public static RequestOptions noAnimation() { } return noAnimationOptions; } + + // Make sure that we're not equal to any other concrete implementation of RequestOptions. + @Override + public boolean equals(Object o) { + return o instanceof RequestOptions && super.equals(o); + } + + // Our class doesn't include any additional properties, so we don't need to modify hashcode, but + // keep it here as a reminder in case we add properties. + @SuppressWarnings("PMD.UselessOverridingMethod") + @Override + public int hashCode() { + return super.hashCode(); + } } diff --git a/library/src/main/java/com/bumptech/glide/request/SingleRequest.java b/library/src/main/java/com/bumptech/glide/request/SingleRequest.java index 84b1c4152a..92cccbacd4 100644 --- a/library/src/main/java/com/bumptech/glide/request/SingleRequest.java +++ b/library/src/main/java/com/bumptech/glide/request/SingleRequest.java @@ -22,6 +22,7 @@ import com.bumptech.glide.request.transition.TransitionFactory; import com.bumptech.glide.util.LogTime; import com.bumptech.glide.util.Util; +import com.bumptech.glide.util.pool.GlideTrace; import com.bumptech.glide.util.pool.StateVerifier; import java.util.List; import java.util.concurrent.Executor; @@ -34,11 +35,13 @@ */ public final class SingleRequest implements Request, SizeReadyCallback, ResourceCallback { /** Tag for logging internal events, not generally suitable for public use. */ - private static final String TAG = "Request"; + private static final String TAG = "GlideRequest"; + /** Tag for logging externally useful events (request completion, timing etc). */ private static final String GLIDE_TAG = "Glide"; private static final boolean IS_VERBOSE_LOGGABLE = Log.isLoggable(TAG, Log.VERBOSE); + private int cookie; private enum Status { /** Created but not yet running. */ @@ -56,7 +59,8 @@ private enum Status { } @Nullable - private final String tag = IS_VERBOSE_LOGGABLE ? String.valueOf(super.hashCode()) : null; + private final String tag = + IS_VERBOSE_LOGGABLE ? String.valueOf(System.identityHashCode(this)) : null; private final StateVerifier stateVerifier = StateVerifier.newInstance(); @@ -246,6 +250,9 @@ public void begin() { // Restarts for requests that are neither complete nor running can be treated as new requests // and can run again from the beginning. + experimentalNotifyRequestStarted(model); + + cookie = GlideTrace.beginSectionAsync(TAG); status = Status.WAITING_FOR_SIZE; if (Util.isValidDimensions(overrideWidth, overrideHeight)) { onSizeReady(overrideWidth, overrideHeight); @@ -263,6 +270,17 @@ && canNotifyStatusChanged()) { } } + private void experimentalNotifyRequestStarted(Object model) { + if (requestListeners == null) { + return; + } + for (RequestListener requestListener : requestListeners) { + if (requestListener instanceof ExperimentalRequestListener) { + ((ExperimentalRequestListener) requestListener).onRequestStarted(model); + } + } + } + /** * Cancels the current load but does not release any resources held by the request and continues * to display the loaded resource if the load completed before the call to cancel. @@ -321,6 +339,7 @@ public void clear() { target.onLoadCleared(getPlaceholderDrawable()); } + GlideTrace.endSectionAsync(TAG, cookie); status = Status.CLEARED; } @@ -403,7 +422,7 @@ private Drawable getFallbackDrawable() { private Drawable loadDrawable(@DrawableRes int resourceId) { Theme theme = requestOptions.getTheme() != null ? requestOptions.getTheme() : context.getTheme(); - return DrawableDecoderCompat.getDrawable(glideContext, resourceId, theme); + return DrawableDecoderCompat.getDrawable(context, resourceId, theme); } @GuardedBy("requestLock") @@ -506,14 +525,14 @@ private boolean isFirstReadyResource() { } @GuardedBy("requestLock") - private void notifyLoadSuccess() { + private void notifyRequestCoordinatorLoadSucceeded() { if (requestCoordinator != null) { requestCoordinator.onRequestSuccess(this); } } @GuardedBy("requestLock") - private void notifyLoadFailed() { + private void notifyRequestCoordinatorLoadFailed() { if (requestCoordinator != null) { requestCoordinator.onRequestFailed(this); } @@ -572,6 +591,7 @@ public void onResourceReady( this.resource = null; // We can't put the status to complete before asking canSetResource(). status = Status.COMPLETE; + GlideTrace.endSectionAsync(TAG, cookie); return; } @@ -621,6 +641,8 @@ private void onResourceReady( + " ms"); } + notifyRequestCoordinatorLoadSucceeded(); + isCallingCallbacks = true; try { boolean anyListenerHandledUpdatingTarget = false; @@ -628,6 +650,14 @@ private void onResourceReady( for (RequestListener listener : requestListeners) { anyListenerHandledUpdatingTarget |= listener.onResourceReady(result, model, target, dataSource, isFirstResource); + + if (listener instanceof ExperimentalRequestListener) { + ExperimentalRequestListener experimentalRequestListener = + (ExperimentalRequestListener) listener; + anyListenerHandledUpdatingTarget |= + experimentalRequestListener.onResourceReady( + result, model, target, dataSource, isFirstResource, isAlternateCacheKey); + } } } anyListenerHandledUpdatingTarget |= @@ -642,7 +672,7 @@ private void onResourceReady( isCallingCallbacks = false; } - notifyLoadSuccess(); + GlideTrace.endSectionAsync(TAG, cookie); } /** A callback method that should never be invoked directly. */ @@ -664,7 +694,9 @@ private void onLoadFailed(GlideException e, int maxLogLevel) { int logLevel = glideContext.getLogLevel(); if (logLevel <= maxLogLevel) { Log.w( - GLIDE_TAG, "Load failed for " + model + " with size [" + width + "x" + height + "]", e); + GLIDE_TAG, + "Load failed for [" + model + "] with dimensions [" + width + "x" + height + "]", + e); if (logLevel <= Log.INFO) { e.logRootCauses(GLIDE_TAG); } @@ -673,6 +705,8 @@ private void onLoadFailed(GlideException e, int maxLogLevel) { loadStatus = null; status = Status.FAILED; + notifyRequestCoordinatorLoadFailed(); + isCallingCallbacks = true; try { // TODO: what if this is a thumbnail request? @@ -694,7 +728,7 @@ private void onLoadFailed(GlideException e, int maxLogLevel) { isCallingCallbacks = false; } - notifyLoadFailed(); + GlideTrace.endSectionAsync(TAG, cookie); } } @@ -707,7 +741,7 @@ public boolean isEquivalentTo(Request o) { int localOverrideWidth; int localOverrideHeight; Object localModel; - Class localTransocdeClass; + Class localTranscodeClass; BaseRequestOptions localRequestOptions; Priority localPriority; int localListenerCount; @@ -715,7 +749,7 @@ public boolean isEquivalentTo(Request o) { localOverrideWidth = overrideWidth; localOverrideHeight = overrideHeight; localModel = model; - localTransocdeClass = transcodeClass; + localTranscodeClass = transcodeClass; localRequestOptions = requestOptions; localPriority = priority; localListenerCount = requestListeners != null ? requestListeners.size() : 0; @@ -725,7 +759,7 @@ public boolean isEquivalentTo(Request o) { int otherLocalOverrideWidth; int otherLocalOverrideHeight; Object otherLocalModel; - Class otherLocalTransocdeClass; + Class otherLocalTranscodeClass; BaseRequestOptions otherLocalRequestOptions; Priority otherLocalPriority; int otherLocalListenerCount; @@ -733,7 +767,7 @@ public boolean isEquivalentTo(Request o) { otherLocalOverrideWidth = other.overrideWidth; otherLocalOverrideHeight = other.overrideHeight; otherLocalModel = other.model; - otherLocalTransocdeClass = other.transcodeClass; + otherLocalTranscodeClass = other.transcodeClass; otherLocalRequestOptions = other.requestOptions; otherLocalPriority = other.priority; otherLocalListenerCount = other.requestListeners != null ? other.requestListeners.size() : 0; @@ -745,8 +779,9 @@ public boolean isEquivalentTo(Request o) { return localOverrideWidth == otherLocalOverrideWidth && localOverrideHeight == otherLocalOverrideHeight && Util.bothModelsNullEquivalentOrEquals(localModel, otherLocalModel) - && localTransocdeClass.equals(otherLocalTransocdeClass) - && localRequestOptions.equals(otherLocalRequestOptions) + && localTranscodeClass.equals(otherLocalTranscodeClass) + && Util.bothBaseRequestOptionsNullEquivalentOrEquals( + localRequestOptions, otherLocalRequestOptions) && localPriority == otherLocalPriority // We do not want to require that RequestListeners implement equals/hashcode, so we // don't compare them using equals(). We can however, at least assert that the same @@ -757,4 +792,20 @@ public boolean isEquivalentTo(Request o) { private void logV(String message) { Log.v(TAG, message + " this: " + tag); } + + @Override + public String toString() { + Object localModel; + Class localTranscodeClass; + synchronized (requestLock) { + localModel = model; + localTranscodeClass = transcodeClass; + } + return super.toString() + + "[model=" + + localModel + + ", transcodeClass=" + + localTranscodeClass + + "]"; + } } diff --git a/library/src/main/java/com/bumptech/glide/request/ThumbnailRequestCoordinator.java b/library/src/main/java/com/bumptech/glide/request/ThumbnailRequestCoordinator.java index 51f05bc4c6..22943d0980 100644 --- a/library/src/main/java/com/bumptech/glide/request/ThumbnailRequestCoordinator.java +++ b/library/src/main/java/com/bumptech/glide/request/ThumbnailRequestCoordinator.java @@ -19,6 +19,7 @@ public class ThumbnailRequestCoordinator implements RequestCoordinator, Request @GuardedBy("requestLock") private RequestState thumbState = RequestState.CLEARED; + // Only used to check if the full request is cleared by the thumbnail request. @GuardedBy("requestLock") private boolean isRunningDuringBegin; diff --git a/library/src/main/java/com/bumptech/glide/request/target/BitmapImageViewTarget.java b/library/src/main/java/com/bumptech/glide/request/target/BitmapImageViewTarget.java index 03b7b5fb23..386a6a1cf7 100644 --- a/library/src/main/java/com/bumptech/glide/request/target/BitmapImageViewTarget.java +++ b/library/src/main/java/com/bumptech/glide/request/target/BitmapImageViewTarget.java @@ -14,7 +14,9 @@ public BitmapImageViewTarget(ImageView view) { super(view); } - /** @deprecated Use {@link #waitForLayout()} instead. */ + /** + * @deprecated Use {@link #waitForLayout()} instead. + */ // Public API. @SuppressWarnings({"unused", "deprecation"}) @Deprecated diff --git a/library/src/main/java/com/bumptech/glide/request/target/BitmapThumbnailImageViewTarget.java b/library/src/main/java/com/bumptech/glide/request/target/BitmapThumbnailImageViewTarget.java index 683367eb5a..90b03a62fb 100644 --- a/library/src/main/java/com/bumptech/glide/request/target/BitmapThumbnailImageViewTarget.java +++ b/library/src/main/java/com/bumptech/glide/request/target/BitmapThumbnailImageViewTarget.java @@ -15,7 +15,9 @@ public BitmapThumbnailImageViewTarget(ImageView view) { super(view); } - /** @deprecated Use {@link #waitForLayout()} instead. */ + /** + * @deprecated Use {@link #waitForLayout()} instead. + */ @SuppressWarnings("deprecation") @Deprecated public BitmapThumbnailImageViewTarget(ImageView view, boolean waitForLayout) { diff --git a/library/src/main/java/com/bumptech/glide/request/target/CustomTarget.java b/library/src/main/java/com/bumptech/glide/request/target/CustomTarget.java index 2d39c3b4c8..45705b6c22 100644 --- a/library/src/main/java/com/bumptech/glide/request/target/CustomTarget.java +++ b/library/src/main/java/com/bumptech/glide/request/target/CustomTarget.java @@ -59,9 +59,10 @@ public CustomTarget() { * as the requested size (unless overridden by {@link * com.bumptech.glide.request.RequestOptions#override(int)} in the request). * - * @param width The requested width (> 0, or == Target.SIZE_ORIGINAL). - * @param height The requested height (> 0, or == Target.SIZE_ORIGINAL). - * @throws IllegalArgumentException if width/height doesn't meet (> 0, or == Target.SIZE_ORIGINAL) + * @param width The requested width in pixels ({@code > 0, or == Target.SIZE_ORIGINAL}). + * @param height The requested height in pixels ({@code > 0, or == Target.SIZE_ORIGINAL}). + * @throws IllegalArgumentException if width/height doesn't meet the requirement: {@code > 0, or + * == Target.SIZE_ORIGINAL} */ public CustomTarget(int width, int height) { if (!Util.isValidDimensions(width, height)) { diff --git a/library/src/main/java/com/bumptech/glide/request/target/DrawableImageViewTarget.java b/library/src/main/java/com/bumptech/glide/request/target/DrawableImageViewTarget.java index e11b46a05f..b27c59b429 100644 --- a/library/src/main/java/com/bumptech/glide/request/target/DrawableImageViewTarget.java +++ b/library/src/main/java/com/bumptech/glide/request/target/DrawableImageViewTarget.java @@ -11,7 +11,9 @@ public DrawableImageViewTarget(ImageView view) { super(view); } - /** @deprecated Use {@link #waitForLayout()} instead. */ + /** + * @deprecated Use {@link #waitForLayout()} instead. + */ // Public API. @SuppressWarnings({"unused", "deprecation"}) @Deprecated diff --git a/library/src/main/java/com/bumptech/glide/request/target/DrawableThumbnailImageViewTarget.java b/library/src/main/java/com/bumptech/glide/request/target/DrawableThumbnailImageViewTarget.java index 41951dcc91..40ff2dad43 100644 --- a/library/src/main/java/com/bumptech/glide/request/target/DrawableThumbnailImageViewTarget.java +++ b/library/src/main/java/com/bumptech/glide/request/target/DrawableThumbnailImageViewTarget.java @@ -13,7 +13,9 @@ public DrawableThumbnailImageViewTarget(ImageView view) { super(view); } - /** @deprecated Use {@link #waitForLayout()} instead. */ + /** + * @deprecated Use {@link #waitForLayout()} instead. + */ @Deprecated @SuppressWarnings("deprecation") public DrawableThumbnailImageViewTarget(ImageView view, boolean waitForLayout) { diff --git a/library/src/main/java/com/bumptech/glide/request/target/ImageViewTarget.java b/library/src/main/java/com/bumptech/glide/request/target/ImageViewTarget.java index ce53193f77..f5f15abab9 100644 --- a/library/src/main/java/com/bumptech/glide/request/target/ImageViewTarget.java +++ b/library/src/main/java/com/bumptech/glide/request/target/ImageViewTarget.java @@ -25,7 +25,9 @@ public ImageViewTarget(ImageView view) { super(view); } - /** @deprecated Use {@link #waitForLayout()} instead. */ + /** + * @deprecated Use {@link #waitForLayout()} instead. + */ @SuppressWarnings({"deprecation"}) @Deprecated public ImageViewTarget(ImageView view, boolean waitForLayout) { diff --git a/library/src/main/java/com/bumptech/glide/request/target/NotificationTarget.java b/library/src/main/java/com/bumptech/glide/request/target/NotificationTarget.java index d958f6220e..9129090456 100644 --- a/library/src/main/java/com/bumptech/glide/request/target/NotificationTarget.java +++ b/library/src/main/java/com/bumptech/glide/request/target/NotificationTarget.java @@ -1,5 +1,7 @@ package com.bumptech.glide.request.target; +import android.Manifest; +import android.annotation.SuppressLint; import android.app.Notification; import android.app.NotificationManager; import android.content.Context; @@ -8,6 +10,7 @@ import android.widget.RemoteViews; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresPermission; import com.bumptech.glide.request.transition.Transition; import com.bumptech.glide.util.Preconditions; @@ -39,6 +42,9 @@ public class NotificationTarget extends CustomTarget { * @param notification The Notification object that we want to update. * @param notificationId The notificationId of the Notification that we want to load the Bitmap. */ + @SuppressLint("InlinedApi") + // Alert users of Glide to have this permission. + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) public NotificationTarget( Context context, int viewId, @@ -61,6 +67,9 @@ public NotificationTarget( * @param notificationTag The notificationTag of the Notification that we want to load the Bitmap. * May be {@code null}. */ + @SuppressLint("InlinedApi") + // Alert users of Glide to have this permission. + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) public NotificationTarget( Context context, int viewId, @@ -95,6 +104,9 @@ public NotificationTarget( * @param notificationTag The notificationTag of the Notification that we want to load the Bitmap. * May be {@code null}. */ + @SuppressLint("InlinedApi") + // Alert users of Glide to have this permission. + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) public NotificationTarget( Context context, int width, @@ -116,6 +128,10 @@ public NotificationTarget( } /** Updates the Notification after the Bitmap resource is loaded. */ + @SuppressLint("InlinedApi") + // Help tools to recognize that this method requires a permission, because it posts a + // notification. + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) private void update() { NotificationManager manager = (NotificationManager) this.context.getSystemService(Context.NOTIFICATION_SERVICE); @@ -123,17 +139,26 @@ private void update() { .notify(this.notificationTag, this.notificationId, this.notification); } + @SuppressLint("InlinedApi") + // Help tools to recognize that this method requires a permission, because it calls setBitmap(). + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) @Override public void onResourceReady( @NonNull Bitmap resource, @Nullable Transition transition) { setBitmap(resource); } + @SuppressLint("InlinedApi") + // Help tools to recognize that this method requires a permission, because it calls setBitmap(). + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) @Override public void onLoadCleared(@Nullable Drawable placeholder) { setBitmap(null); } + @SuppressLint("InlinedApi") + // Help tools to recognize that this method requires a permission, because it calls update(). + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) private void setBitmap(@Nullable Bitmap bitmap) { this.remoteViews.setImageViewBitmap(this.viewId, bitmap); this.update(); diff --git a/library/src/main/java/com/bumptech/glide/request/target/PreloadTarget.java b/library/src/main/java/com/bumptech/glide/request/target/PreloadTarget.java index 9521005322..6d3cb587b3 100644 --- a/library/src/main/java/com/bumptech/glide/request/target/PreloadTarget.java +++ b/library/src/main/java/com/bumptech/glide/request/target/PreloadTarget.java @@ -8,6 +8,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.bumptech.glide.RequestManager; +import com.bumptech.glide.request.Request; import com.bumptech.glide.request.transition.Transition; import com.bumptech.glide.util.Synthetic; @@ -53,7 +54,17 @@ private PreloadTarget(RequestManager requestManager, int width, int height) { @Override public void onResourceReady(@NonNull Z resource, @Nullable Transition transition) { - HANDLER.obtainMessage(MESSAGE_CLEAR, this).sendToTarget(); + // If a thumbnail request is set and the thumbnail completes, we don't want to cancel the + // primary load. Instead we wait until the primary request (the one set on the target) says + // that it is complete. + // Note - Any thumbnail request that does not complete before the primary request will be + // cancelled and may not be preloaded successfully. Cancellation of outstanding thumbnails after + // the primary request succeeds is a common behavior of all Glide requests and we're not trying + // to override it here. + Request request = getRequest(); + if (request != null && request.isComplete()) { + HANDLER.obtainMessage(MESSAGE_CLEAR, this).sendToTarget(); + } } @Override diff --git a/library/src/main/java/com/bumptech/glide/request/target/SimpleTarget.java b/library/src/main/java/com/bumptech/glide/request/target/SimpleTarget.java index 388253dcc1..80f8c4a9bb 100644 --- a/library/src/main/java/com/bumptech/glide/request/target/SimpleTarget.java +++ b/library/src/main/java/com/bumptech/glide/request/target/SimpleTarget.java @@ -10,8 +10,7 @@ * implementations of non essential methods that allows the caller to specify an exact width/height. * Typically use cases look something like this: * - *

- * 
+ * 
{@code
  * Target target =
  *     Glide.with(fragment)
  *       .asBitmap()
@@ -29,8 +28,7 @@
  * // At some later point, clear the Target to release the resources, prevent load queues from
  * // blowing out proportion, and to improve load times for future requests:
  * Glide.with(fragment).clear(target);
- * 
- * 
+ * }
* *

Warning! this class is extremely prone to mis-use. Use SimpleTarget only as a last * resort. {@link ViewTarget} or a subclass of {@link ViewTarget} is almost always a better choice. diff --git a/library/src/main/java/com/bumptech/glide/request/target/Target.java b/library/src/main/java/com/bumptech/glide/request/target/Target.java index 292b705a66..495d465e2c 100644 --- a/library/src/main/java/com/bumptech/glide/request/target/Target.java +++ b/library/src/main/java/com/bumptech/glide/request/target/Target.java @@ -20,10 +20,11 @@ *

  • onLoadFailed * * - * The typical lifecycle is onLoadStarted -> onResourceReady or onLoadFailed -> onLoadCleared. - * However, there are no guarantees. onLoadStarted may not be called if the resource is in memory or - * if the load will fail because of a null model object. onLoadCleared similarly may never be called - * if the target is never cleared. See the docs for the individual methods for details. + *

    The typical lifecycle is onLoadStarted, then onResourceReady or onLoadFailed, then + * onLoadCleared. However, there are no guarantees. onLoadStarted may not be called if the resource + * is in memory or if the load will fail because of a null model object. onLoadCleared similarly may + * never be called if the target is never cleared. See the docs for the individual methods for + * details. * * @param The type of resource the target can display. */ @@ -62,6 +63,21 @@ public interface Target extends LifecycleListener { /** * The method that will be called when the resource load has finished. * + *

    This may be called multiple times both within a single load and also across different loads + * if the {@code Target} object is re-used. + * + *

    Within a single load this may be called multiple times for reasons that include: + * + *

      + *
    • The load uses one or more thumbnails. Each time a thumbnail load completes successfully + * and no higher priority load has finished, this method will be called with the thumbnail + * resource. See {@link + * com.bumptech.glide.RequestBuilder#thumbnail(com.bumptech.glide.RequestBuilder)}. + *
    • The load is paused and restarted. This can happen automatically in response to + * connectivity changes or the Activity / Fragment lifecycle. It can also happen if {@link + * com.bumptech.glide.RequestManager#pauseRequests()} is called manually. + *
    + * * @param resource the loaded resource. */ void onResourceReady(@NonNull R resource, @Nullable Transition transition); diff --git a/library/src/main/java/com/bumptech/glide/request/target/ThumbnailImageViewTarget.java b/library/src/main/java/com/bumptech/glide/request/target/ThumbnailImageViewTarget.java index d8b2689ead..f7b208a58b 100644 --- a/library/src/main/java/com/bumptech/glide/request/target/ThumbnailImageViewTarget.java +++ b/library/src/main/java/com/bumptech/glide/request/target/ThumbnailImageViewTarget.java @@ -27,7 +27,9 @@ public ThumbnailImageViewTarget(ImageView view) { super(view); } - /** @deprecated Use {@link #waitForLayout()} insetad. */ + /** + * @deprecated Use {@link #waitForLayout()} insetad. + */ @Deprecated @SuppressWarnings({"deprecation"}) public ThumbnailImageViewTarget(ImageView view, boolean waitForLayout) { diff --git a/library/src/main/java/com/bumptech/glide/request/transition/DrawableCrossFadeFactory.java b/library/src/main/java/com/bumptech/glide/request/transition/DrawableCrossFadeFactory.java index 58cf57d42d..eeddb75fd7 100644 --- a/library/src/main/java/com/bumptech/glide/request/transition/DrawableCrossFadeFactory.java +++ b/library/src/main/java/com/bumptech/glide/request/transition/DrawableCrossFadeFactory.java @@ -50,7 +50,9 @@ public Builder() { this(DEFAULT_DURATION_MS); } - /** @param durationMillis The duration of the cross fade animation in milliseconds. */ + /** + * @param durationMillis The duration of the cross fade animation in milliseconds. + */ public Builder(int durationMillis) { this.durationMillis = durationMillis; } diff --git a/library/src/main/java/com/bumptech/glide/util/ByteBufferUtil.java b/library/src/main/java/com/bumptech/glide/util/ByteBufferUtil.java index 073e3456eb..a9a928f885 100644 --- a/library/src/main/java/com/bumptech/glide/util/ByteBufferUtil.java +++ b/library/src/main/java/com/bumptech/glide/util/ByteBufferUtil.java @@ -2,6 +2,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; @@ -10,6 +11,8 @@ import java.io.RandomAccessFile; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.atomic.AtomicReference; /** Utilities for interacting with {@link java.nio.ByteBuffer}s. */ @@ -133,6 +136,12 @@ public static InputStream toStream(@NonNull ByteBuffer buffer) { @NonNull public static ByteBuffer fromStream(@NonNull InputStream stream) throws IOException { + return fromStream(stream, false /* useHeapBuffer */); + } + + @NonNull + public static ByteBuffer fromStream(@NonNull InputStream stream, boolean useHeapBuffer) + throws IOException { ByteArrayOutputStream outStream = new ByteArrayOutputStream(BUFFER_SIZE); byte[] buffer = BUFFER_REF.getAndSet(null); @@ -140,17 +149,91 @@ public static ByteBuffer fromStream(@NonNull InputStream stream) throws IOExcept buffer = new byte[BUFFER_SIZE]; } - int n; - while ((n = stream.read(buffer)) >= 0) { - outStream.write(buffer, 0, n); + try { + int n; + while ((n = stream.read(buffer)) >= 0) { + outStream.write(buffer, 0, n); + } + } finally { + BUFFER_REF.set(buffer); } - BUFFER_REF.set(buffer); - byte[] bytes = outStream.toByteArray(); - // Some resource decoders require a direct byte buffer. Prefer allocateDirect() over wrap() - return rewind(ByteBuffer.allocateDirect(bytes.length).put(bytes)); + if (useHeapBuffer) { + return ByteBuffer.wrap(bytes); + } else { + // Some resource decoders require a direct byte buffer. Prefer allocateDirect() over wrap() + return rewind(ByteBuffer.allocateDirect(bytes.length).put(bytes)); + } + } + + /** + * Creates a {@link ByteBuffer} from an {@link InputStream}, using the provided {@link ArrayPool} + * to recycle intermediate reading buffers. + * + * @param stream The {@link InputStream} to read from. + * @param useHeapBuffer True to allocate a heap {@link ByteBuffer}, false for a direct {@link + * ByteBuffer}. + * @param arrayPool The {@link ArrayPool} used to pool and recycle temporary byte arrays. + * @return A {@link ByteBuffer} containing the full contents of the stream. + * @throws IOException If reading from the stream fails. + */ + @NonNull + public static ByteBuffer fromStream( + @NonNull InputStream stream, boolean useHeapBuffer, @NonNull ArrayPool arrayPool) + throws IOException { + List buffers = new ArrayList<>(); + int totalSize = 0; + byte[] currentBuffer = null; + boolean success = false; + try { + while (true) { + currentBuffer = arrayPool.get(BUFFER_SIZE, byte[].class); + int read = 0; + while (read < BUFFER_SIZE) { + int count = stream.read(currentBuffer, read, BUFFER_SIZE - read); + if (count == -1) { + break; + } + read += count; + } + if (read == 0) { + arrayPool.put(currentBuffer); + currentBuffer = null; + break; + } + buffers.add(currentBuffer); + currentBuffer = null; + totalSize += read; + if (read < BUFFER_SIZE) { + break; + } + } + + ByteBuffer result = + useHeapBuffer ? ByteBuffer.allocate(totalSize) : ByteBuffer.allocateDirect(totalSize); + + int remaining = totalSize; + for (byte[] b : buffers) { + int toPut = Math.min(remaining, BUFFER_SIZE); + result.put(b, 0, toPut); + remaining -= toPut; + arrayPool.put(b); + } + buffers.clear(); + success = true; + return rewind(result); + } finally { + if (!success) { + if (currentBuffer != null) { + arrayPool.put(currentBuffer); + } + for (byte[] b : buffers) { + arrayPool.put(b); + } + } + } } public static ByteBuffer rewind(ByteBuffer buffer) { diff --git a/library/src/main/java/com/bumptech/glide/util/Executors.java b/library/src/main/java/com/bumptech/glide/util/Executors.java index 6a73055469..ed194edf41 100644 --- a/library/src/main/java/com/bumptech/glide/util/Executors.java +++ b/library/src/main/java/com/bumptech/glide/util/Executors.java @@ -19,6 +19,13 @@ public void execute(@NonNull Runnable command) { Util.postOnUiThread(command); } }; + private static final Executor MAIN_THREAD_EXECUTOR_FRONT = + new Executor() { + @Override + public void execute(@NonNull Runnable command) { + Util.postAtFrontOfQueueOnUiThread(command); + } + }; private static final Executor DIRECT_EXECUTOR = new Executor() { @Override @@ -32,6 +39,11 @@ public static Executor mainThreadExecutor() { return MAIN_THREAD_EXECUTOR; } + /** Posts executions to the main thread at the front of the queue. */ + public static Executor mainThreadExecutorFront() { + return MAIN_THREAD_EXECUTOR_FRONT; + } + /** Immediately calls {@link Runnable#run()} on the current thread. */ public static Executor directExecutor() { return DIRECT_EXECUTOR; diff --git a/library/src/main/java/com/bumptech/glide/util/GlideSuppliers.java b/library/src/main/java/com/bumptech/glide/util/GlideSuppliers.java new file mode 100644 index 0000000000..0003873b4b --- /dev/null +++ b/library/src/main/java/com/bumptech/glide/util/GlideSuppliers.java @@ -0,0 +1,33 @@ +package com.bumptech.glide.util; + +/** Similar to {@link com.google.common.base.Suppliers}, but named to reduce import confusion. */ +public final class GlideSuppliers { + /** + * Produces a non-null instance of {@code T}. + * + * @param The data type + */ + public interface GlideSupplier { + T get(); + } + + private GlideSuppliers() {} + + public static GlideSupplier memorize(final GlideSupplier supplier) { + return new GlideSupplier() { + private volatile T instance; + + @Override + public T get() { + if (instance == null) { + synchronized (this) { + if (instance == null) { + instance = Preconditions.checkNotNull(supplier.get()); + } + } + } + return instance; + } + }; + } +} diff --git a/library/src/main/java/com/bumptech/glide/util/MultiClassKey.java b/library/src/main/java/com/bumptech/glide/util/MultiClassKey.java index dd8450a867..e0c54e8dc3 100644 --- a/library/src/main/java/com/bumptech/glide/util/MultiClassKey.java +++ b/library/src/main/java/com/bumptech/glide/util/MultiClassKey.java @@ -5,7 +5,7 @@ /** A key of two {@link Class}es to be used in hashed collections. */ @SuppressWarnings({"PMD.ConstructorCallsOverridableMethod"}) -public class MultiClassKey { +public final class MultiClassKey { private Class first; private Class second; private Class third; diff --git a/library/src/main/java/com/bumptech/glide/util/Preconditions.java b/library/src/main/java/com/bumptech/glide/util/Preconditions.java index e9a696a5bb..44be5c2caf 100644 --- a/library/src/main/java/com/bumptech/glide/util/Preconditions.java +++ b/library/src/main/java/com/bumptech/glide/util/Preconditions.java @@ -12,6 +12,10 @@ private Preconditions() { // Utility class. } + public static void checkArgument(boolean expression) { + checkArgument(expression, /* message= */ ""); + } + public static void checkArgument(boolean expression, @NonNull String message) { if (!expression) { throw new IllegalArgumentException(message); diff --git a/library/src/main/java/com/bumptech/glide/util/Util.java b/library/src/main/java/com/bumptech/glide/util/Util.java index eb81dc1ae1..e3823e32fa 100644 --- a/library/src/main/java/com/bumptech/glide/util/Util.java +++ b/library/src/main/java/com/bumptech/glide/util/Util.java @@ -5,9 +5,11 @@ import android.os.Build; import android.os.Handler; import android.os.Looper; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.bumptech.glide.load.model.Model; +import com.bumptech.glide.request.BaseRequestOptions; import com.bumptech.glide.request.target.Target; import java.util.ArrayDeque; import java.util.ArrayList; @@ -101,7 +103,13 @@ public static int getBitmapByteSize(int width, int height, @Nullable Bitmap.Conf return width * height * getBytesPerPixel(config); } - private static int getBytesPerPixel(@Nullable Bitmap.Config config) { + /** + * Returns the number of bytes required to store each pixel of a {@link Bitmap} with the given + * {@code config}. + * + *

    Defaults to {@link Bitmap.Config#ARGB_8888} if {@code config} is {@code null}. + */ + public static int getBytesPerPixel(@Nullable Bitmap.Config config) { // A bitmap by decoding a GIF has null "config" in certain environments. if (config == null) { config = Bitmap.Config.ARGB_8888; @@ -127,12 +135,15 @@ private static int getBytesPerPixel(@Nullable Bitmap.Config config) { return bytesPerPixel; } - /** Returns true if width and height are both > 0 and/or equal to {@link Target#SIZE_ORIGINAL}. */ + /** + * Returns {@code true} if {@code width} and {@code height} are both {@code > 0} and/or equal to + * {@link Target#SIZE_ORIGINAL}. + */ public static boolean isValidDimensions(int width, int height) { return isValidDimension(width) && isValidDimension(height); } - private static boolean isValidDimension(int dimen) { + public static boolean isValidDimension(int dimen) { return dimen > 0 || dimen == Target.SIZE_ORIGINAL; } @@ -141,6 +152,14 @@ public static void postOnUiThread(Runnable runnable) { getUiThreadHandler().post(runnable); } + /** + * Posts the given {@code runnable} to the front of the queue on the UI thread using a shared + * {@link Handler}. + */ + public static void postAtFrontOfQueueOnUiThread(Runnable runnable) { + getUiThreadHandler().postAtFrontOfQueue(runnable); + } + /** Removes the given {@code runnable} from the UI threads queue if it is still queued. */ public static void removeCallbacksOnUiThread(Runnable runnable) { getUiThreadHandler().removeCallbacks(runnable); @@ -231,6 +250,14 @@ public static boolean bothModelsNullEquivalentOrEquals(@Nullable Object a, @Null return a.equals(b); } + public static boolean bothBaseRequestOptionsNullEquivalentOrEquals( + @Nullable BaseRequestOptions a, @Nullable BaseRequestOptions b) { + if (a == null) { + return b == null; + } + return a.isEquivalentTo(b); + } + public static int hashCode(int value) { return hashCode(value, HASH_ACCUMULATOR); } @@ -258,4 +285,71 @@ public static int hashCode(boolean value, int accumulator) { public static int hashCode(boolean value) { return hashCode(value, HASH_ACCUMULATOR); } + + /** + * Logs detailed memory tracking information for a Bitmap allocation or scaling operation. + * + *

    This method performs logging only and does not modify or transform the provided bitmap in + * any way. + * + * @param tag The log tag to use. + * @param context The component or operation context (e.g. "Downsampler", "TransformationUtils"). + * @param strategyName The name of the scaling or downsampling strategy used, or {@code null}. + * @param downsampled The resulting {@link Bitmap} after allocation or scaling. + * @param sourceWidth The original width of the source image before scaling. + * @param sourceHeight The original height of the source image before scaling. + */ + public static void logMemoryTracking( + String tag, + String context, + String strategyName, + Bitmap downsampled, + int sourceWidth, + int sourceHeight) { + int originalMemory = Util.getBitmapByteSize(sourceWidth, sourceHeight, downsampled.getConfig()); + int expectedDecodedMemory = + Util.getBitmapByteSize( + downsampled.getWidth(), downsampled.getHeight(), downsampled.getConfig()); + int actualAllocatedMemory = Util.getBitmapByteSize(downsampled); + int trueCost = expectedDecodedMemory - originalMemory; + int poolOverhead = actualAllocatedMemory - expectedDecodedMemory; + int bitmapIdentity = System.identityHashCode(downsampled); + int decodedArea = downsampled.getWidth() * downsampled.getHeight(); + int sourceArea = sourceWidth * sourceHeight; + String scaleAction = "no scaling"; + if (decodedArea > sourceArea) { + scaleAction = "upscaled"; + } else if (decodedArea < sourceArea) { + scaleAction = "downscaled"; + } + String strategyClause = strategyName == null ? "" : " (Strategy: " + strategyName + ")"; + String poolInfo = + (poolOverhead > 0) ? " [Pooled: +" + poolOverhead + " bytes buffer overhead]" : ""; + Log.d( + tag, + context + + " [Device: " + + android.os.Build.DEVICE + + "]: Decoded bitmap [ID: " + + bitmapIdentity + + "] " + + scaleAction + + strategyClause + + " from [" + + sourceWidth + + "x" + + sourceHeight + + "] (" + + originalMemory + + " bytes) to [" + + downsampled.getWidth() + + "x" + + downsampled.getHeight() + + "] (" + + expectedDecodedMemory + + " bytes). True cost: " + + trueCost + + " bytes" + + poolInfo); + } } diff --git a/library/src/main/java/com/bumptech/glide/util/ViewPreloadSizeProvider.java b/library/src/main/java/com/bumptech/glide/util/ViewPreloadSizeProvider.java index 16a1af36ad..8b90ec1aec 100644 --- a/library/src/main/java/com/bumptech/glide/util/ViewPreloadSizeProvider.java +++ b/library/src/main/java/com/bumptech/glide/util/ViewPreloadSizeProvider.java @@ -16,9 +16,10 @@ * * @param The type of the model the size should be provided for. */ -public class ViewPreloadSizeProvider +public final class ViewPreloadSizeProvider implements ListPreloader.PreloadSizeProvider, SizeReadyCallback { private int[] size; + // We need to keep a strong reference to the Target so that it isn't garbage collected due to a // weak reference // while we're waiting to get its size. diff --git a/library/src/main/java/com/bumptech/glide/util/pool/FactoryPools.java b/library/src/main/java/com/bumptech/glide/util/pool/FactoryPools.java index 96600b2d3d..d3b1a26c74 100644 --- a/library/src/main/java/com/bumptech/glide/util/pool/FactoryPools.java +++ b/library/src/main/java/com/bumptech/glide/util/pool/FactoryPools.java @@ -41,6 +41,15 @@ public static Pool simple(int size, @NonNull Factory return build(new SimplePool(size), factory); } + /** + * Identical to {@link #threadSafe(int, Factory, Resetter)} except no action is taken when an + * instance is returned to the pool. + */ + @NonNull + public static Pool threadSafe(int size, @NonNull Factory factory) { + return build(new SynchronizedPool(size), factory); + } + /** * Returns a new thread safe {@link Pool} that never returns {@code null} from {@link * Pool#acquire()} and that contains objects of the type created by the given {@link Factory} with @@ -49,11 +58,15 @@ public static Pool simple(int size, @NonNull Factory *

    If the pool is empty when {@link Pool#acquire()} is called, the given {@link Factory} will * be used to create a new instance. * + *

    Each time an instance is returned to the pool {@code resetter} will be called with the given + * instance. + * * @param The type of object the pool will contains. */ @NonNull - public static Pool threadSafe(int size, @NonNull Factory factory) { - return build(new SynchronizedPool(size), factory); + public static Pool threadSafe( + int size, @NonNull Factory factory, @NonNull Resetter resetter) { + return build(new SynchronizedPool(size), factory, resetter); } /** diff --git a/library/src/main/java/com/bumptech/glide/util/pool/GlideTrace.java b/library/src/main/java/com/bumptech/glide/util/pool/GlideTrace.java index 207511f3af..c6f2cf5a1a 100644 --- a/library/src/main/java/com/bumptech/glide/util/pool/GlideTrace.java +++ b/library/src/main/java/com/bumptech/glide/util/pool/GlideTrace.java @@ -1,13 +1,15 @@ package com.bumptech.glide.util.pool; -import androidx.core.os.TraceCompat; +import androidx.tracing.Trace; +import java.util.concurrent.atomic.AtomicInteger; /** Systracing utilities for Glide. */ public final class GlideTrace { - // Enable this locally to see tracing statements. private static final boolean TRACING_ENABLED = false; + private static final AtomicInteger COOKIE_CREATOR = TRACING_ENABLED ? new AtomicInteger() : null; + /** Maximum length of a systrace tag. */ private static final int MAX_LENGTH = 127; @@ -24,31 +26,46 @@ private static String truncateTag(String tag) { public static void beginSection(String tag) { if (TRACING_ENABLED) { - TraceCompat.beginSection(truncateTag(tag)); + Trace.beginSection(truncateTag(tag)); } } public static void beginSectionFormat(String format, Object arg1) { if (TRACING_ENABLED) { - TraceCompat.beginSection(truncateTag(String.format(format, arg1))); + Trace.beginSection(truncateTag(String.format(format, arg1))); } } public static void beginSectionFormat(String format, Object arg1, Object arg2) { if (TRACING_ENABLED) { - TraceCompat.beginSection(truncateTag(String.format(format, arg1, arg2))); + Trace.beginSection(truncateTag(String.format(format, arg1, arg2))); } } public static void beginSectionFormat(String format, Object arg1, Object arg2, Object arg3) { if (TRACING_ENABLED) { - TraceCompat.beginSection(truncateTag(String.format(format, arg1, arg2, arg3))); + Trace.beginSection(truncateTag(String.format(format, arg1, arg2, arg3))); + } + } + + public static int beginSectionAsync(String tag) { + if (TRACING_ENABLED) { + int cookie = COOKIE_CREATOR.incrementAndGet(); + Trace.beginAsyncSection(truncateTag(tag), cookie); + return cookie; + } + return -1; + } + + public static void endSectionAsync(String tag, int cookie) { + if (TRACING_ENABLED) { + Trace.endAsyncSection(tag, cookie); } } public static void endSection() { if (TRACING_ENABLED) { - TraceCompat.endSection(); + Trace.endSection(); } } } diff --git a/library/src/test/java/com/bumptech/glide/load/resource/bitmap/InputStreamBitmapImageDecoderResourceDecoderTest.java b/library/src/test/java/com/bumptech/glide/load/resource/bitmap/InputStreamBitmapImageDecoderResourceDecoderTest.java new file mode 100644 index 0000000000..06b866d02c --- /dev/null +++ b/library/src/test/java/com/bumptech/glide/load/resource/bitmap/InputStreamBitmapImageDecoderResourceDecoderTest.java @@ -0,0 +1,86 @@ +package com.bumptech.glide.load.resource.bitmap; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.bumptech.glide.load.ImageHeaderParser; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.engine.bitmap_recycle.LruArrayPool; +import com.google.common.collect.ImmutableList; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public final class InputStreamBitmapImageDecoderResourceDecoderTest { + + private Options options; + private ImmutableList parsers; + + @Before + public void setUp() { + options = new Options(); + parsers = ImmutableList.of(new DefaultImageHeaderParser()); + } + + @Test + public void decode_withHeapBuffer_readsFullStream() throws IOException { + InputStreamBitmapImageDecoderResourceDecoder decoder = + new InputStreamBitmapImageDecoderResourceDecoder( + parsers, /* useHeapBuffer= */ true, /* arrayPool= */ null, /* useArrayPool= */ false); + byte[] data = new byte[] {1, 2, 3, 4}; + InputStream stream = new ByteArrayInputStream(data); + + try { + decoder.decode(stream, 100, 100, options); + } catch (Exception e) { + // Expecting potential failure due to missing shadows in unit test environment, + // but the stream should still be read. + } + + // Verify that the stream was fully read + assertThat(stream.read()).isEqualTo(-1); + } + + @Test + public void decode_withDirectBuffer_readsFullStream() throws IOException { + InputStreamBitmapImageDecoderResourceDecoder decoder = + new InputStreamBitmapImageDecoderResourceDecoder( + parsers, /* useHeapBuffer= */ false, /* arrayPool= */ null, /* useArrayPool= */ false); + byte[] data = new byte[] {1, 2, 3, 4}; + InputStream stream = new ByteArrayInputStream(data); + + try { + decoder.decode(stream, 100, 100, options); + } catch (Exception e) { + // Expecting potential failure due to missing shadows in unit test environment. + } + + // Verify that the stream was fully read + assertThat(stream.read()).isEqualTo(-1); + } + + @Test + public void decode_withArrayPool_readsFullStream() throws IOException { + InputStreamBitmapImageDecoderResourceDecoder decoder = + new InputStreamBitmapImageDecoderResourceDecoder( + parsers, + /* useHeapBuffer= */ true, + new LruArrayPool(1024 * 1024), + /* useArrayPool= */ true); + byte[] data = new byte[] {1, 2, 3, 4}; + InputStream stream = new ByteArrayInputStream(data); + + try { + decoder.decode(stream, 100, 100, options); + } catch (Exception e) { + // Expecting potential failure due to missing shadows in unit test environment. + } + + // Verify that the stream was fully read + assertThat(stream.read()).isEqualTo(-1); + } +} diff --git a/library/src/test/java/com/bumptech/glide/load/resource/bitmap/UriBitmapImageDecoderResourceDecoderTest.java b/library/src/test/java/com/bumptech/glide/load/resource/bitmap/UriBitmapImageDecoderResourceDecoderTest.java new file mode 100644 index 0000000000..2fc377df34 --- /dev/null +++ b/library/src/test/java/com/bumptech/glide/load/resource/bitmap/UriBitmapImageDecoderResourceDecoderTest.java @@ -0,0 +1,137 @@ +package com.bumptech.glide.load.resource.bitmap; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.net.Uri; +import android.os.Build; +import android.provider.MediaStore; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.engine.Resource; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +@RunWith(AndroidJUnit4.class) +@Config(sdk = Build.VERSION_CODES.Q) +public final class UriBitmapImageDecoderResourceDecoderTest { + + private Context context; + private UriBitmapImageDecoderResourceDecoder decoder; + private Options options; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + decoder = new UriBitmapImageDecoderResourceDecoder(context); + options = new Options(); + } + + @Test + public void handles_returnsTrueForUri() throws IOException { + ContentValues values = new ContentValues(); + values.put(MediaStore.Images.Media.MIME_TYPE, "image/png"); + Uri uri = + context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); + assertThat(decoder.handles(uri, options)).isTrue(); + } + + @Test + public void decode_withNonExistentUri_throwsIOException() { + Uri uri = Uri.parse("file:///non-existent-file.png"); + assertThrows( + IOException.class, () -> decoder.decode(uri, /* width= */ 100, /* height= */ 100, options)); + } + + @Test + public void handles_returnsTrueForFileUri() throws IOException { + Uri uri = Uri.parse("file:///path/to/image.png"); + assertThat(decoder.handles(uri, options)).isTrue(); + } + + @Test + public void handles_returnsTrueForResourceUri() throws IOException { + Uri uri = Uri.parse("android.resource://com.bumptech.glide.test/raw/image"); + assertThat(decoder.handles(uri, options)).isTrue(); + } + + @Test + public void handles_returnsFalseForHttpUri() throws IOException { + Uri uri = Uri.parse("http://example.com/image.png"); + assertThat(decoder.handles(uri, options)).isFalse(); + } + + @Test + public void handles_returnsFalseForGifUri() throws IOException { + ContentValues values = new ContentValues(); + values.put(MediaStore.Images.Media.MIME_TYPE, "image/gif"); + Uri uri = + context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); + assertThat(decoder.handles(uri, options)).isFalse(); + } + + @Test + public void decode_solidColor_returnsExactColor() throws IOException { + Bitmap bmp = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bmp); + canvas.drawColor(Color.RED); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + bmp.compress(Bitmap.CompressFormat.PNG, 0, out); + + ContentValues values = new ContentValues(); + values.put(MediaStore.Images.Media.MIME_TYPE, "image/png"); + Uri uri = + context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); + + try (OutputStream os = context.getContentResolver().openOutputStream(uri)) { + out.writeTo(os); + } + + Resource resource = decoder.decode(uri, /* width= */ 100, /* height= */ 100, options); + Bitmap decoded = resource.get(); + + assertThat(decoded.getPixel(0, 0)).isEqualTo(Color.RED); + } + + @Test + public void handles_returnsFalseForVideoUri() throws IOException { + ContentValues values = new ContentValues(); + values.put(MediaStore.Video.Media.MIME_TYPE, "video/mp4"); + Uri uri = + context.getContentResolver().insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values); + assertThat(decoder.handles(uri, options)).isFalse(); + } + + @Test + public void handles_returnsFalseForVideoFileUri() throws IOException { + Uri uri = Uri.parse("file:///path/to/video.mp4"); + assertThat(decoder.handles(uri, options)).isFalse(); + } + + @Test + public void handles_returnsFalseForTextUri() throws IOException { + ContentValues values = new ContentValues(); + values.put(MediaStore.Files.FileColumns.MIME_TYPE, "text/plain"); + Uri uri = + context.getContentResolver().insert(MediaStore.Files.getContentUri("external"), values); + assertThat(decoder.handles(uri, options)).isFalse(); + } + + @Test + public void handles_returnsFalseForUnknownExtensionFileUri() throws IOException { + Uri uri = Uri.parse("file:///path/to/file.unknown"); + assertThat(decoder.handles(uri, options)).isFalse(); + } +} diff --git a/library/src/test/java/com/bumptech/glide/request/target/CustomViewTargetTest.java b/library/src/test/java/com/bumptech/glide/request/target/CustomViewTargetTest.java index 3eaf85a374..d2f9e607b6 100644 --- a/library/src/test/java/com/bumptech/glide/request/target/CustomViewTargetTest.java +++ b/library/src/test/java/com/bumptech/glide/request/target/CustomViewTargetTest.java @@ -1,6 +1,5 @@ package com.bumptech.glide.request.target; -import static android.view.ViewGroup.LayoutParams; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; @@ -14,22 +13,19 @@ import static org.mockito.Mockito.when; import android.app.Activity; -import android.content.Context; import android.graphics.drawable.Drawable; import android.os.Build; -import android.view.Display; import android.view.View; import android.view.View.OnAttachStateChangeListener; import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; import android.view.ViewTreeObserver; -import android.view.WindowManager; import android.widget.FrameLayout; import android.widget.LinearLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.bumptech.glide.request.Request; import com.bumptech.glide.request.transition.Transition; -import com.bumptech.glide.util.Preconditions; import com.google.common.truth.Truth; import java.util.concurrent.atomic.AtomicInteger; import org.junit.After; @@ -41,12 +37,8 @@ import org.mockito.MockitoAnnotations; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; -import org.robolectric.Shadows; import org.robolectric.android.controller.ActivityController; import org.robolectric.annotation.Config; -import org.robolectric.annotation.TextLayoutMode; -import org.robolectric.util.ReflectionHelpers; /** * Test for {@link CustomViewTarget}. @@ -56,8 +48,7 @@ * gradle changes, but I've so far failed to figure out the right set of commands. */ @RunWith(RobolectricTestRunner.class) -@Config(sdk = 19, manifest = "build/intermediates/manifests/full/debug/AndroidManifest.xml") -@TextLayoutMode(value = TextLayoutMode.Mode.LEGACY, issueId = "130378660") +@Config(sdk = Config.OLDEST_SDK) public class CustomViewTargetTest { private ActivityController activity; private View view; @@ -65,12 +56,10 @@ public class CustomViewTargetTest { private CustomViewTarget target; @Mock private SizeReadyCallback cb; @Mock private Request request; - private int sdkVersion; private AttachStateTarget attachStateTarget; @Before public void setUp() { - sdkVersion = Build.VERSION.SDK_INT; MockitoAnnotations.initMocks(this); activity = Robolectric.buildActivity(Activity.class).create().start().postCreate(null).resume(); view = new View(activity.get()); @@ -80,7 +69,7 @@ public void setUp() { LinearLayout linearLayout = new LinearLayout(activity.get()); View expandView = new View(activity.get()); LinearLayout.LayoutParams linearLayoutParams = - new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, /*height=*/ 0); + new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, /* height= */ 0); linearLayoutParams.weight = 1f; expandView.setLayoutParams(linearLayoutParams); linearLayout.addView(expandView); @@ -94,7 +83,6 @@ public void setUp() { @After public void tearDown() { - setSdkVersionInt(sdkVersion); CustomViewTarget.SizeDeterminer.maxDisplayLength = null; } @@ -147,14 +135,13 @@ public void testSizeCallbackIsCalledSynchronouslyIfLayoutParamsConcreteSizeSet() verify(cb).onSizeReady(eq(dimens), eq(dimens)); } + @Config(qualifiers = "w200dp-h300dp") @Test public void getSize_withBothWrapContent_usesDisplayDimens() { LayoutParams layoutParams = new FrameLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); view.setLayoutParams(layoutParams); - setDisplayDimens(200, 300); - activity.visible(); view.layout(0, 0, 0, 0); @@ -163,14 +150,13 @@ public void getSize_withBothWrapContent_usesDisplayDimens() { verify(cb).onSizeReady(300, 300); } + @Config(qualifiers = "w100dp-h200dp") @Test public void getSize_withWrapContentWidthAndValidHeight_usesDisplayDimenAndValidHeight() { int height = 100; LayoutParams params = new FrameLayout.LayoutParams(LayoutParams.WRAP_CONTENT, height); view.setLayoutParams(params); - setDisplayDimens(100, 200); - activity.visible(); view.setRight(0); @@ -179,12 +165,12 @@ public void getSize_withWrapContentWidthAndValidHeight_usesDisplayDimenAndValidH verify(cb).onSizeReady(200, height); } + @Config(qualifiers = "w200dp-h100dp") @Test public void getSize_withWrapContentHeightAndValidWidth_returnsWidthAndDisplayDimen() { int width = 100; LayoutParams params = new FrameLayout.LayoutParams(width, LayoutParams.WRAP_CONTENT); view.setLayoutParams(params); - setDisplayDimens(200, 100); parent.getLayoutParams().height = 200; activity.visible(); @@ -194,14 +180,13 @@ public void getSize_withWrapContentHeightAndValidWidth_returnsWidthAndDisplayDim verify(cb).onSizeReady(width, 200); } + @Config(qualifiers = "w500dp-h600dp") @Test public void getSize_withWrapContentWidthAndMatchParentHeight_usesDisplayDimenWidthAndHeight() { LayoutParams params = new FrameLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT); view.setLayoutParams(params); - setDisplayDimens(500, 600); - target.getSize(cb); verify(cb, never()).onSizeReady(anyInt(), anyInt()); @@ -212,17 +197,16 @@ public void getSize_withWrapContentWidthAndMatchParentHeight_usesDisplayDimenWid view.getViewTreeObserver().dispatchOnPreDraw(); - verify(cb).onSizeReady(600, height); + verify(cb).onSizeReady(500, height); } + @Config(qualifiers = "w300dp-h400dp") @Test public void getSize_withMatchParentWidthAndWrapContentHeight_usesWidthAndDisplayDimenHeight() { LayoutParams params = new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); view.setLayoutParams(params); - setDisplayDimens(300, 400); - target.getSize(cb); verify(cb, never()).onSizeReady(anyInt(), anyInt()); @@ -232,7 +216,11 @@ public void getSize_withMatchParentWidthAndWrapContentHeight_usesWidthAndDisplay activity.visible(); view.getViewTreeObserver().dispatchOnPreDraw(); - verify(cb).onSizeReady(width, 400); + if (Build.VERSION.SDK_INT <= 19) { + verify(cb).onSizeReady(width, 352); + } else { + verify(cb).onSizeReady(width, 344); + } } @Test @@ -273,7 +261,8 @@ public void testSizeCallbacksAreCalledInOrderPreDraw() { target.getSize(cbs[i]); } - int width = 100, height = 111; + int width = 100; + int height = 111; parent.getLayoutParams().width = width; parent.getLayoutParams().height = height; activity.visible(); @@ -450,7 +439,6 @@ public void getSize_withLayoutParams_emptyParams_notLaidOutOrLayoutRequested_cal @Test public void getSize_withValidWidthAndHeight_preV19_layoutRequested_callsSizeReady() { - setSdkVersionInt(18); view.setLayoutParams(new FrameLayout.LayoutParams(100, 100)); view.requestLayout(); @@ -470,19 +458,6 @@ public void getSize_withWidthAndHeightEqualToPadding_doesNotCallSizeReady() { verify(cb, never()).onSizeReady(anyInt(), anyInt()); } - private void setDisplayDimens(Integer width, Integer height) { - WindowManager windowManager = - (WindowManager) RuntimeEnvironment.application.getSystemService(Context.WINDOW_SERVICE); - Display display = Preconditions.checkNotNull(windowManager).getDefaultDisplay(); - if (width != null) { - Shadows.shadowOf(display).setWidth(width); - } - - if (height != null) { - Shadows.shadowOf(display).setHeight(height); - } - } - @Test public void clearOnDetach_onDetach_withNullRequest_doesNothing() { attachStateTarget.clearOnDetach(); @@ -517,7 +492,7 @@ public void clearOnDetach_onDetach_withRunningRequest_pausesRequestOnce() { public void clearOnDetach_onDetach_afterOnLoadCleared_removesListener() { activity.visible(); attachStateTarget.clearOnDetach(); - attachStateTarget.onLoadCleared(/*placeholder=*/ null); + attachStateTarget.onLoadCleared(/* placeholder= */ null); attachStateTarget.setRequest(request); parent.removeView(view); @@ -538,7 +513,7 @@ public void clearOnDetach_moreThanOnce_registersObserverOnce() { public void clearOnDetach_onDetach_afterMultipleClearOnDetaches_removesListener() { activity.visible(); attachStateTarget.clearOnDetach().clearOnDetach().clearOnDetach(); - attachStateTarget.onLoadCleared(/*placeholder=*/ null); + attachStateTarget.onLoadCleared(/* placeholder= */ null); attachStateTarget.setRequest(request); parent.removeView(view); @@ -588,8 +563,8 @@ public void clearOnDetach_afterLoadClearedAndRestarted_onAttach_beginsRequest() attachStateTarget.clearOnDetach(); attachStateTarget.setRequest(request); when(request.isCleared()).thenReturn(true); - attachStateTarget.onLoadCleared(/*placeholder=*/ null); - attachStateTarget.onLoadStarted(/*placeholder=*/ null); + attachStateTarget.onLoadCleared(/* placeholder= */ null); + attachStateTarget.onLoadStarted(/* placeholder= */ null); activity.visible(); verify(request).begin(); @@ -600,7 +575,7 @@ public void clearOnDetach_onAttach_afterLoadCleared_doesNotBeingRequest() { attachStateTarget.clearOnDetach(); attachStateTarget.setRequest(request); when(request.isCleared()).thenReturn(true); - attachStateTarget.onLoadCleared(/*placeholder=*/ null); + attachStateTarget.onLoadCleared(/* placeholder= */ null); activity.visible(); verify(request, never()).begin(); @@ -610,7 +585,7 @@ public void clearOnDetach_onAttach_afterLoadCleared_doesNotBeingRequest() { public void onLoadStarted_withoutClearOnDetach_doesNotAddListener() { activity.visible(); target.setRequest(request); - attachStateTarget.onLoadStarted(/*placeholder=*/ null); + attachStateTarget.onLoadStarted(/* placeholder= */ null); parent.removeView(view); verify(request, never()).clear(); @@ -633,7 +608,7 @@ public void onViewDetachedFromWindow(View v) { }; view.addOnAttachStateChangeListener(expected); - attachStateTarget.onLoadCleared(/*placeholder=*/ null); + attachStateTarget.onLoadCleared(/* placeholder= */ null); activity.visible(); @@ -695,8 +670,4 @@ public void onLoadFailed(@Nullable Drawable errorDrawable) { // Avoid calling super. } } - - private static void setSdkVersionInt(int version) { - ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", version); - } } diff --git a/library/src/test/resources/robolectric.properties b/library/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..189df8cfae --- /dev/null +++ b/library/src/test/resources/robolectric.properties @@ -0,0 +1,4 @@ +# Cap Robolectric target SDK to 34 because the active Robolectric 4.11.1 version +# in this project only supports simulation up to Android SDK 34 (maxSdkVersion=34). +# Using targetSdkVersion 35/36 causes sandbox initialization failures and GHA CI network hangs. +sdk=34 diff --git a/library/test/build.gradle b/library/test/build.gradle deleted file mode 100644 index 49349392c6..0000000000 --- a/library/test/build.gradle +++ /dev/null @@ -1,64 +0,0 @@ -apply plugin: 'com.android.library' - -dependencies { - testImplementation "androidx.appcompat:appcompat:${ANDROID_X_VERSION}" - testImplementation project(':library') - testImplementation project(':mocks') - testImplementation project(':testutil') - testImplementation 'com.google.guava:guava-testlib:18.0' - testImplementation "com.google.truth:truth:${TRUTH_VERSION}" - testImplementation "junit:junit:${JUNIT_VERSION}" - testImplementation "org.mockito:mockito-core:${MOCKITO_VERSION}" - testImplementation "org.robolectric:robolectric:${ROBOLECTRIC_VERSION}" - testImplementation "com.squareup.okhttp3:mockwebserver:${MOCKWEBSERVER_VERSION}" - testImplementation "androidx.legacy:legacy-support-v4:${ANDROID_X_VERSION}" - testImplementation "androidx.test:core:${ANDROID_X_VERSION}" -} - -tasks.withType(JavaCompile) { - options.fork = true -} - -afterEvaluate { - lint.enabled = false - compileDebugJavaWithJavac.enabled = false -} - -android.testOptions.unitTests.all { Test testTask -> - // configure max heap size of the test JVM - testTask.maxHeapSize = TEST_JVM_MEMORY_SIZE as String - if (JavaVersion.current() <= JavaVersion.VERSION_1_7) { - // Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=2048m; support was removed in 8.0 - testTask.jvmArgs "-XX:MaxPermSize=${TEST_JVM_MEMORY_SIZE}" - } - - // Initializing Robolectric is expensive, two threads seem to be around the only level where any - // improvement is seen. - testTask.maxParallelForks = 2 -} - -android { - compileSdkVersion COMPILE_SDK_VERSION as int - - defaultConfig { - minSdkVersion MIN_SDK_VERSION as int - targetSdkVersion TARGET_SDK_VERSION as int - versionName VERSION_NAME as String - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 - } - - testOptions.unitTests.includeAndroidResources = true - - sourceSets { - androidTest { - resources.srcDirs += ['../../third_party/exif_orientation_examples'] - } - test { - resources.srcDirs += ['../../third_party/exif_orientation_examples'] - } - } -} diff --git a/library/test/build.gradle.kts b/library/test/build.gradle.kts new file mode 100644 index 0000000000..4efc7d646b --- /dev/null +++ b/library/test/build.gradle.kts @@ -0,0 +1,67 @@ +import org.gradle.api.JavaVersion +import org.gradle.api.tasks.compile.JavaCompile + +plugins { + id("com.android.library") +} + +tasks.withType().configureEach { + options.setFork(true) +} + +android { + testOptions.unitTests.all { testTask -> + testTask.maxHeapSize = rootProject.extra.get("TEST_JVM_MEMORY_SIZE") as String + + if (JavaVersion.current() <= JavaVersion.VERSION_1_8) { + testTask.jvmArgs("-XX:MaxPermSize=${rootProject.extra.get("TEST_JVM_MEMORY_SIZE")}") + } + + testTask.maxParallelForks = 2 + } + + namespace = "com.bumptech.glide.test" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + testOptions.unitTests.isIncludeAndroidResources = true + + sourceSets { + getByName("androidTest") { + resources.directories.add("../../exifsamples") + } + getByName("test") { + resources.directories.add("../../exifsamples") + } + } +} + +dependencies { + testImplementation(libs.androidx.appcompat) + testImplementation(project(":library")) + testImplementation(project(":mocks")) + testImplementation(project(":testutil")) + testImplementation(libs.guava.testlib) + testImplementation(libs.truth) + testImplementation(libs.junit) + testImplementation(libs.mockito.core) + testImplementation(libs.robolectric) + testImplementation(libs.mockwebserver) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.junit) + testImplementation(libs.androidx.test.runner) +} + +afterEvaluate { + tasks.named("lint").configure { enabled = false } + tasks.named("compileReleaseJavaWithJavac").configure { enabled = false } + tasks.named("compileDebugJavaWithJavac").configure { enabled = false } +} \ No newline at end of file diff --git a/library/test/src/main/AndroidManifest.xml b/library/test/src/main/AndroidManifest.xml deleted file mode 100644 index 600ac91f9c..0000000000 --- a/library/test/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/library/test/src/test/java/com/bumptech/glide/GlideContextTest.java b/library/test/src/test/java/com/bumptech/glide/GlideContextTest.java index 5ffcf5d951..26ce8dfb92 100644 --- a/library/test/src/test/java/com/bumptech/glide/GlideContextTest.java +++ b/library/test/src/test/java/com/bumptech/glide/GlideContextTest.java @@ -18,6 +18,7 @@ import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.RequestOptions; import com.bumptech.glide.request.target.ImageViewTargetFactory; +import com.bumptech.glide.util.GlideSuppliers.GlideSupplier; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -40,7 +41,12 @@ public void setUp() { new GlideContext( app, new LruArrayPool(), - new Registry(), + new GlideSupplier() { + @Override + public Registry get() { + return new Registry(); + } + }, new ImageViewTargetFactory(), new RequestOptionsFactory() { @NonNull @@ -50,7 +56,7 @@ public RequestOptions build() { } }, transitionOptions, - /*defaultRequestListeners=*/ Collections.>emptyList(), + /* defaultRequestListeners= */ Collections.>emptyList(), mock(Engine.class), mock(GlideExperiments.class), Log.DEBUG); diff --git a/library/test/src/test/java/com/bumptech/glide/GlideExperimentsTest.java b/library/test/src/test/java/com/bumptech/glide/GlideExperimentsTest.java new file mode 100644 index 0000000000..987bcafeb0 --- /dev/null +++ b/library/test/src/test/java/com/bumptech/glide/GlideExperimentsTest.java @@ -0,0 +1 @@ +package com.bumptech.glide; diff --git a/library/test/src/test/java/com/bumptech/glide/GlideTest.java b/library/test/src/test/java/com/bumptech/glide/GlideTest.java index 0fb1085c28..8a5500e20d 100644 --- a/library/test/src/test/java/com/bumptech/glide/GlideTest.java +++ b/library/test/src/test/java/com/bumptech/glide/GlideTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.bumptech.glide.request.RequestOptions.decodeTypeOf; import static com.bumptech.glide.request.RequestOptions.errorOf; import static com.bumptech.glide.request.RequestOptions.placeholderOf; @@ -26,9 +27,7 @@ import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; -import android.media.MediaMetadataRetriever; import android.net.Uri; -import android.os.Handler; import android.os.ParcelFileDescriptor; import android.view.ViewGroup; import android.widget.ImageView; @@ -52,6 +51,7 @@ import com.bumptech.glide.load.resource.gif.GifDrawable; import com.bumptech.glide.manager.Lifecycle; import com.bumptech.glide.manager.RequestManagerTreeNode; +import com.bumptech.glide.module.GlideModule; import com.bumptech.glide.request.Request; import com.bumptech.glide.request.RequestListener; import com.bumptech.glide.request.RequestOptions; @@ -59,7 +59,6 @@ import com.bumptech.glide.request.target.SizeReadyCallback; import com.bumptech.glide.request.target.Target; import com.bumptech.glide.request.transition.Transition; -import com.bumptech.glide.tests.GlideShadowLooper; import com.bumptech.glide.tests.TearDownGlide; import com.bumptech.glide.tests.Util; import com.bumptech.glide.testutil.TestResourceUtil; @@ -69,6 +68,7 @@ import java.net.MalformedURLException; import java.net.URL; import java.nio.ByteBuffer; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import org.junit.Before; @@ -76,30 +76,26 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; import org.robolectric.RobolectricTestRunner; -import org.robolectric.Shadows; import org.robolectric.annotation.Config; import org.robolectric.annotation.Implementation; import org.robolectric.annotation.Implements; import org.robolectric.annotation.LooperMode; import org.robolectric.annotation.Resetter; import org.robolectric.shadow.api.Shadow; -import org.robolectric.shadows.ShadowBitmap; /** Tests for the {@link Glide} interface and singleton. */ @LooperMode(LEGACY) @RunWith(RobolectricTestRunner.class) @Config( - sdk = 18, + sdk = ROBOLECTRIC_SDK, shadows = { GlideTest.ShadowFileDescriptorContentResolver.class, - GlideTest.ShadowMediaMetadataRetriever.class, - GlideShadowLooper.class, - GlideTest.MutableShadowBitmap.class }) @SuppressWarnings("unchecked") public class GlideTest { @@ -115,7 +111,6 @@ public class GlideTest { @Mock private DiskCache.Factory diskCacheFactory; @Mock private DiskCache diskCache; @Mock private MemoryCache memoryCache; - @Mock private Handler bgHandler; @Mock private Lifecycle lifecycle; @Mock private RequestManagerTreeNode treeNode; @Mock private BitmapPool bitmapPool; @@ -154,17 +149,6 @@ public void setUp() { imageView.layout(0, 0, 100, 100); doAnswer(new CallSizeReady()).when(target).getSize(isA(SizeReadyCallback.class)); - when(bgHandler.post(isA(Runnable.class))) - .thenAnswer( - new Answer() { - @Override - public Boolean answer(InvocationOnMock invocation) { - Runnable runnable = (Runnable) invocation.getArguments()[0]; - runnable.run(); - return true; - } - }); - requestManager = new RequestManager(Glide.get(context), lifecycle, treeNode, context); requestManager.resumeRequests(); } @@ -172,8 +156,7 @@ public Boolean answer(InvocationOnMock invocation) { @Test public void testCanSetMemoryCategory() { MemoryCategory memoryCategory = MemoryCategory.NORMAL; - Glide glide = - new GlideBuilder().setBitmapPool(bitmapPool).setMemoryCache(memoryCache).build(context); + Glide glide = buildGlideWithFakePools(); glide.setMemoryCategory(memoryCategory); verify(memoryCache).setSizeMultiplier(eq(memoryCategory.getMultiplier())); @@ -183,8 +166,7 @@ public void testCanSetMemoryCategory() { @Test public void testCanIncreaseMemoryCategory() { MemoryCategory memoryCategory = MemoryCategory.NORMAL; - Glide glide = - new GlideBuilder().setBitmapPool(bitmapPool).setMemoryCache(memoryCache).build(context); + Glide glide = buildGlideWithFakePools(); glide.setMemoryCategory(memoryCategory); verify(memoryCache).setSizeMultiplier(eq(memoryCategory.getMultiplier())); @@ -202,8 +184,7 @@ public void testCanIncreaseMemoryCategory() { @Test public void testCanDecreaseMemoryCategory() { MemoryCategory memoryCategory = MemoryCategory.NORMAL; - Glide glide = - new GlideBuilder().setBitmapPool(bitmapPool).setMemoryCache(memoryCache).build(context); + Glide glide = buildGlideWithFakePools(); glide.setMemoryCategory(memoryCategory); verify(memoryCache).setSizeMultiplier(eq(memoryCategory.getMultiplier())); @@ -220,8 +201,7 @@ public void testCanDecreaseMemoryCategory() { @Test public void testClearMemory() { - Glide glide = - new GlideBuilder().setBitmapPool(bitmapPool).setMemoryCache(memoryCache).build(context); + Glide glide = buildGlideWithFakePools(); glide.clearMemory(); @@ -231,8 +211,7 @@ public void testClearMemory() { @Test public void testTrimMemory() { - Glide glide = - new GlideBuilder().setBitmapPool(bitmapPool).setMemoryCache(memoryCache).build(context); + Glide glide = buildGlideWithFakePools(); final int level = 123; @@ -242,6 +221,16 @@ public void testTrimMemory() { verify(memoryCache).trimMemory(eq(level)); } + private Glide buildGlideWithFakePools() { + return new GlideBuilder() + .setBitmapPool(bitmapPool) + .setMemoryCache(memoryCache) + .build( + context, + Collections.emptyList(), + /* annotationGeneratedGlideModule= */ null); + } + @Test public void testFileDefaultLoaderWithInputStream() { registerFailFactory(File.class, ParcelFileDescriptor.class); @@ -432,17 +421,17 @@ private void runTestStringDefaultLoader(String string) { public boolean onLoadFailed( GlideException e, Object model, - Target target, + @NonNull Target target, boolean isFirstResource) { throw new RuntimeException("Load failed"); } @Override public boolean onResourceReady( - Drawable resource, - Object model, + @NonNull Drawable resource, + @NonNull Object model, Target target, - DataSource dataSource, + @NonNull DataSource dataSource, boolean isFirstResource) { return false; } @@ -710,7 +699,8 @@ public void testClone() { firstRequest.clone().apply(placeholderOf(new ColorDrawable(Color.RED))).into(secondTarget); verify(firstTarget).onResourceReady(isA(Drawable.class), isA(Transition.class)); - verify(secondTarget).onResourceReady(notNull(Drawable.class), isA(Transition.class)); + verify(secondTarget) + .onResourceReady(ArgumentMatchers.notNull(), isA(Transition.class)); } @SuppressWarnings("unchecked") @@ -870,27 +860,4 @@ public AssetFileDescriptor openAssetFileDescriptor(Uri uri, String type) { return URI_TO_FILE_DESCRIPTOR.get(uri); } } - - @Implements(Bitmap.class) - public static class MutableShadowBitmap extends ShadowBitmap { - - @Implementation - public static Bitmap createBitmap(int width, int height, Bitmap.Config config) { - Bitmap bitmap = ShadowBitmap.createBitmap(width, height, config); - Shadows.shadowOf(bitmap).setMutable(true); - return bitmap; - } - } - - @Implements(MediaMetadataRetriever.class) - public static class ShadowMediaMetadataRetriever { - - @Implementation - @SuppressWarnings("unused") - public Bitmap getFrameAtTime() { - Bitmap bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888); - Shadows.shadowOf(bitmap).appendDescription(" from MediaMetadataRetriever"); - return bitmap; - } - } } diff --git a/library/test/src/test/java/com/bumptech/glide/InitializeGlideTest.java b/library/test/src/test/java/com/bumptech/glide/InitializeGlideTest.java new file mode 100644 index 0000000000..7a5f7d13fc --- /dev/null +++ b/library/test/src/test/java/com/bumptech/glide/InitializeGlideTest.java @@ -0,0 +1,85 @@ +package com.bumptech.glide; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.bumptech.glide.tests.TearDownGlide; +import java.util.Set; +import org.junit.Rule; +import org.junit.Test; +import org.junit.function.ThrowingRunnable; +import org.junit.runner.RunWith; + +// This test is about edge cases that might otherwise make debugging more challenging. +@RunWith(AndroidJUnit4.class) +public class InitializeGlideTest { + @Rule public final TearDownGlide tearDownGlide = new TearDownGlide(); + private final Context context = ApplicationProvider.getApplicationContext(); + + private static final class TestException extends RuntimeException { + private static final long serialVersionUID = 2515021766931124927L; + } + + @Test + public void initialize_whenInternalMethodThrows_throwsException() { + assertThrows( + TestException.class, + new ThrowingRunnable() { + @Override + public void run() { + synchronized (Glide.class) { + Glide.checkAndInitializeGlide( + context, + new GeneratedAppGlideModule() { + @NonNull + @Override + Set> getExcludedModuleClasses() { + throw new TestException(); + } + }); + } + } + }); + } + + @Test + public void initialize_whenInternalMethodThrows_andCalledTwice_throwsException() { + GeneratedAppGlideModule throwingGeneratedAppGlideModule = + new GeneratedAppGlideModule() { + @NonNull + @Override + Set> getExcludedModuleClasses() { + throw new TestException(); + } + }; + ThrowingRunnable initializeGlide = + new ThrowingRunnable() { + @Override + public void run() throws Throwable { + synchronized (Glide.class) { + Glide.checkAndInitializeGlide(context, throwingGeneratedAppGlideModule); + } + } + }; + + assertThrows(TestException.class, initializeGlide); + // Make sure the second exception isn't hidden by some Glide initialization related exception. + assertThrows(TestException.class, initializeGlide); + } + + @Test + public void isInitialized_whenNotInitialized_returnsFalse() { + assertThat(Glide.isInitialized()).isFalse(); + } + + @Test + public void isInitialized_whenInitialized_returnsTrue() { + Glide.get(context); + + assertThat(Glide.isInitialized()).isTrue(); + } +} diff --git a/library/test/src/test/java/com/bumptech/glide/ListPreloaderTest.java b/library/test/src/test/java/com/bumptech/glide/ListPreloaderTest.java index 7d1f1aacd8..b5e5f52389 100644 --- a/library/test/src/test/java/com/bumptech/glide/ListPreloaderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/ListPreloaderTest.java @@ -30,7 +30,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK) public class ListPreloaderTest { @Mock private RequestBuilder request; diff --git a/library/test/src/test/java/com/bumptech/glide/RegistryFactoryTest.java b/library/test/src/test/java/com/bumptech/glide/RegistryFactoryTest.java new file mode 100644 index 0000000000..38353f99ff --- /dev/null +++ b/library/test/src/test/java/com/bumptech/glide/RegistryFactoryTest.java @@ -0,0 +1,175 @@ +package com.bumptech.glide; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import android.content.ContentValues; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.provider.MediaStore; +import androidx.annotation.NonNull; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.bumptech.glide.load.resource.gif.GifDrawable; +import com.bumptech.glide.module.AppGlideModule; +import com.bumptech.glide.tests.TearDownGlide; +import com.bumptech.glide.util.GlideSuppliers.GlideSupplier; +import com.google.common.collect.ImmutableList; +import java.io.OutputStream; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.Rule; +import org.junit.Test; +import org.junit.function.ThrowingRunnable; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +@RunWith(AndroidJUnit4.class) +@Config(sdk = Build.VERSION_CODES.Q) +public class RegistryFactoryTest { + @Rule public final TearDownGlide tearDownGlide = new TearDownGlide(); + private final Context context = ApplicationProvider.getApplicationContext(); + + private static final class TestException extends RuntimeException { + private static final long serialVersionUID = 2334956185897161236L; + } + + @Test + public void create_whenCalledTwiceWithThrowingModule_throwsOriginalException() { + AppGlideModule throwingAppGlideModule = + new AppGlideModule() { + @Override + public void registerComponents( + @NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { + throw new TestException(); + } + }; + + Glide glide = Glide.get(context); + GlideSupplier registrySupplier = + RegistryFactory.lazilyCreateAndInitializeRegistry( + glide, /* manifestModules= */ ImmutableList.of(), throwingAppGlideModule); + + assertThrows( + TestException.class, + new ThrowingRunnable() { + @Override + public void run() { + registrySupplier.get(); + } + }); + + assertThrows( + TestException.class, + new ThrowingRunnable() { + @Override + public void run() { + registrySupplier.get(); + } + }); + } + + @Test + public void lazilyCreate_whenImageDecoderEnabled_localGifLoadsAsAnimated() throws Exception { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return; + } + GlideBuilder builder = new GlideBuilder(); + builder.setImageDecoderEnabledForBitmaps(true); + builder.setUriImageDecoderEnabled(true); + Glide.init(context, builder); + + ContentValues values = new ContentValues(); + values.put(MediaStore.Images.Media.MIME_TYPE, "image/gif"); + Uri gifUri = + context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); + try (OutputStream os = context.getContentResolver().openOutputStream(gifUri)) { + os.write(TINY_GIF); + } + + // Load the GIF as a Drawable on a background thread. + Future future = + Executors.newSingleThreadExecutor() + .submit(() -> Glide.with(context).asDrawable().load(gifUri).submit().get()); + + Drawable drawable = future.get(5, TimeUnit.SECONDS); + + assertThat(drawable).isInstanceOf(GifDrawable.class); + } + + @Test + public void testImageDecoderDecodesTinyGif() throws Exception { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return; + } + ContentValues values = new ContentValues(); + values.put(MediaStore.Images.Media.MIME_TYPE, "image/gif"); + Uri gifUri = + context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); + try (OutputStream os = context.getContentResolver().openOutputStream(gifUri)) { + os.write(TINY_GIF); + } + + android.graphics.ImageDecoder.Source source = + android.graphics.ImageDecoder.createSource(context.getContentResolver(), gifUri); + try { + Bitmap bitmap = android.graphics.ImageDecoder.decodeBitmap(source); + System.out.println("ImageDecoder succeeded: " + bitmap); + } catch (Throwable t) { + System.out.println("ImageDecoder failed!"); + t.printStackTrace(); + throw new RuntimeException(t); + } + } + + private static final byte[] TINY_GIF = + new byte[] { + 0x47, + 0x49, + 0x46, + 0x38, + 0x39, + 0x61, + 0x01, + 0x00, + 0x01, + 0x00, + (byte) 0x80, + 0x00, + 0x00, + (byte) 0xff, + (byte) 0xff, + (byte) 0xff, + 0x00, + 0x00, + 0x00, + 0x21, + (byte) 0xf9, + 0x04, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x2c, + 0x00, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x01, + 0x00, + 0x00, + 0x02, + 0x02, + 0x44, + 0x01, + 0x00, + 0x3b + }; +} diff --git a/library/test/src/test/java/com/bumptech/glide/RequestBuilderTest.java b/library/test/src/test/java/com/bumptech/glide/RequestBuilderTest.java index f8dfb9d9a9..ae371b7988 100644 --- a/library/test/src/test/java/com/bumptech/glide/RequestBuilderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/RequestBuilderTest.java @@ -11,9 +11,13 @@ import static org.mockito.Mockito.when; import android.app.Application; +import android.net.Uri; import android.widget.ImageView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.test.core.app.ApplicationProvider; import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.GlideException; import com.bumptech.glide.load.resource.SimpleResource; import com.bumptech.glide.request.Request; import com.bumptech.glide.request.RequestListener; @@ -23,6 +27,8 @@ import com.bumptech.glide.request.target.ViewTarget; import com.bumptech.glide.tests.BackgroundUtil.BackgroundTester; import com.bumptech.glide.tests.TearDownGlide; +import com.google.common.testing.EqualsTester; +import java.util.concurrent.Executors; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -36,7 +42,7 @@ @SuppressWarnings("unchecked") @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK) public class RequestBuilderTest { @Rule public TearDownGlide tearDownGlide = new TearDownGlide(); @@ -72,6 +78,11 @@ public void testDoesNotThrowWithNullModelWhenRequestIsBuilt() { getNullModelRequest().into(target); } + @Test + public void testDoesNotThrowWithNullModelWhenRequestIsBuiltFront() { + getNullModelRequest().experimentalIntoFront(target); + } + @Test public void testAddsNewRequestToRequestTracker() { getNullModelRequest().into(target); @@ -79,6 +90,21 @@ public void testAddsNewRequestToRequestTracker() { verify(requestManager).track(eq(target), isA(Request.class)); } + @Test + public void testAddsNewRequestToRequestTrackerWithCustomExecutor() { + getNullModelRequest() + .into(target, /* targetListener= */ null, Executors.newSingleThreadExecutor()); + + verify(requestManager).track(eq(target), isA(Request.class)); + } + + @Test + public void testAddsNewRequestToRequestTrackerFront() { + getNullModelRequest().experimentalIntoFront(target); + + verify(requestManager).track(eq(target), isA(Request.class)); + } + @Test public void testRemovesPreviousRequestFromRequestTracker() { Request previous = mock(Request.class); @@ -89,17 +115,38 @@ public void testRemovesPreviousRequestFromRequestTracker() { verify(requestManager).clear(eq(target)); } + @Test + public void testRemovesPreviousRequestFromRequestTrackerFront() { + Request previous = mock(Request.class); + when(target.getRequest()).thenReturn(previous); + + getNullModelRequest().experimentalIntoFront(target); + + verify(requestManager).clear(eq(target)); + } + @Test(expected = NullPointerException.class) public void testThrowsIfGivenNullTarget() { //noinspection ConstantConditions testing if @NonNull is enforced getNullModelRequest().into((Target) null); } + @Test(expected = NullPointerException.class) + public void testThrowsIfGivenNullTargetFront() { + //noinspection ConstantConditions testing if @NonNull is enforced + getNullModelRequest().experimentalIntoFront((Target) null); + } + @Test(expected = NullPointerException.class) public void testThrowsIfGivenNullView() { getNullModelRequest().into((ImageView) null); } + @Test(expected = NullPointerException.class) + public void testThrowsIfGivenNullViewFront() { + getNullModelRequest().experimentalIntoFront((ImageView) null); + } + @Test(expected = RuntimeException.class) public void testThrowsIfIntoViewCalledOnBackgroundThread() throws InterruptedException { final ImageView imageView = new ImageView(ApplicationProvider.getApplicationContext()); @@ -112,6 +159,18 @@ public void runTest() { }); } + @Test(expected = RuntimeException.class) + public void testThrowsIfIntoViewCalledOnBackgroundThreadFront() throws InterruptedException { + final ImageView imageView = new ImageView(ApplicationProvider.getApplicationContext()); + testInBackground( + new BackgroundTester() { + @Override + public void runTest() { + getNullModelRequest().experimentalIntoFront(imageView); + } + }); + } + @Test public void doesNotThrowIfIntoTargetCalledOnBackgroundThread() throws InterruptedException { final Target target = mock(Target.class); @@ -124,6 +183,32 @@ public void runTest() { }); } + @Test + public void doesNotThrowIfIntoTargetCalledOnBackgroundThreadFront() throws InterruptedException { + final Target target = mock(Target.class); + testInBackground( + new BackgroundTester() { + @Override + public void runTest() { + getNullModelRequest().experimentalIntoFront(target); + } + }); + } + + @Test + public void doesNotThrowIfIntoTargetWithCustomExecutorCalledOnBackgroundThread() + throws InterruptedException { + final Target target = mock(Target.class); + testInBackground( + new BackgroundTester() { + @Override + public void runTest() { + getNullModelRequest() + .into(target, /* targetListener= */ null, Executors.newSingleThreadExecutor()); + } + }); + } + @Test public void testMultipleRequestListeners() { getNullModelRequest().addListener(listener1).addListener(listener2).into(target); @@ -133,7 +218,27 @@ public void testMultipleRequestListeners() { .onResourceReady( new SimpleResource<>(new Object()), DataSource.LOCAL, - /*isLoadedFromAlternateCacheKey=*/ false); + /* isLoadedFromAlternateCacheKey= */ false); + + verify(listener1) + .onResourceReady(any(), any(), isA(Target.class), isA(DataSource.class), anyBoolean()); + verify(listener2) + .onResourceReady(any(), any(), isA(Target.class), isA(DataSource.class), anyBoolean()); + } + + @Test + public void testMultipleRequestListenersFront() { + getNullModelRequest() + .addListener(listener1) + .addListener(listener2) + .experimentalIntoFront(target); + verify(requestManager).track(any(Target.class), requestCaptor.capture()); + requestCaptor + .getValue() + .onResourceReady( + new SimpleResource<>(new Object()), + DataSource.LOCAL, + /* isLoadedFromAlternateCacheKey= */ false); verify(listener1) .onResourceReady(any(), any(), isA(Target.class), isA(DataSource.class), anyBoolean()); @@ -150,7 +255,25 @@ public void testListenerApiOverridesListeners() { .onResourceReady( new SimpleResource<>(new Object()), DataSource.LOCAL, - /*isLoadedFromAlternateCacheKey=*/ false); + /* isLoadedFromAlternateCacheKey= */ false); + + // The #listener API removes any previous listeners, so the first listener should not be called. + verify(listener1, never()) + .onResourceReady(any(), any(), isA(Target.class), isA(DataSource.class), anyBoolean()); + verify(listener2) + .onResourceReady(any(), any(), isA(Target.class), isA(DataSource.class), anyBoolean()); + } + + @Test + public void testListenerApiOverridesListenersFront() { + getNullModelRequest().addListener(listener1).listener(listener2).experimentalIntoFront(target); + verify(requestManager).track(any(Target.class), requestCaptor.capture()); + requestCaptor + .getValue() + .onResourceReady( + new SimpleResource<>(new Object()), + DataSource.LOCAL, + /* isLoadedFromAlternateCacheKey= */ false); // The #listener API removes any previous listeners, so the first listener should not be called. verify(listener1, never()) @@ -159,13 +282,128 @@ public void testListenerApiOverridesListeners() { .onResourceReady(any(), any(), isA(Target.class), isA(DataSource.class), anyBoolean()); } + @Test + public void testEquals() { + Object firstModel = new Object(); + Object secondModel = new Object(); + + RequestListener firstListener = + new RequestListener<>() { + @Override + public boolean onLoadFailed( + @Nullable GlideException e, + Object model, + @NonNull Target target, + boolean isFirstResource) { + return false; + } + + @Override + public boolean onResourceReady( + @NonNull Object resource, + @NonNull Object model, + Target target, + @NonNull DataSource dataSource, + boolean isFirstResource) { + return false; + } + }; + RequestListener secondListener = + new RequestListener<>() { + @Override + public boolean onLoadFailed( + @Nullable GlideException e, + Object model, + @NonNull Target target, + boolean isFirstResource) { + return false; + } + + @Override + public boolean onResourceReady( + @NonNull Object resource, + @NonNull Object model, + Target target, + @NonNull DataSource dataSource, + boolean isFirstResource) { + return false; + } + }; + + new EqualsTester() + .addEqualityGroup(new Object()) + .addEqualityGroup(newRequestBuilder(Object.class), newRequestBuilder(Object.class)) + .addEqualityGroup( + newRequestBuilder(Object.class).load((Object) null), + newRequestBuilder(Object.class).load((Object) null), + newRequestBuilder(Object.class).load((Uri) null)) + .addEqualityGroup( + newRequestBuilder(Object.class).load(firstModel), + newRequestBuilder(Object.class).load(firstModel)) + .addEqualityGroup( + newRequestBuilder(Object.class).load(secondModel), + newRequestBuilder(Object.class).load(secondModel)) + .addEqualityGroup( + newRequestBuilder(Object.class).load(Uri.EMPTY), + newRequestBuilder(Object.class).load(Uri.EMPTY)) + .addEqualityGroup( + newRequestBuilder(Uri.class).load(Uri.EMPTY), + newRequestBuilder(Uri.class).load(Uri.EMPTY)) + .addEqualityGroup( + newRequestBuilder(Object.class).centerCrop(), + newRequestBuilder(Object.class).centerCrop()) + .addEqualityGroup( + newRequestBuilder(Object.class).addListener(firstListener), + newRequestBuilder(Object.class).addListener(firstListener)) + .addEqualityGroup( + newRequestBuilder(Object.class).addListener(secondListener), + newRequestBuilder(Object.class).addListener(secondListener)) + .addEqualityGroup( + newRequestBuilder(Object.class).error(newRequestBuilder(Object.class)), + newRequestBuilder(Object.class).error(newRequestBuilder(Object.class))) + .addEqualityGroup( + newRequestBuilder(Object.class).error(firstModel), + newRequestBuilder(Object.class).error(firstModel), + newRequestBuilder(Object.class).error(newRequestBuilder(Object.class).load(firstModel))) + .addEqualityGroup( + newRequestBuilder(Object.class).error(secondModel), + newRequestBuilder(Object.class).error(secondModel), + newRequestBuilder(Object.class) + .error(newRequestBuilder(Object.class).load(secondModel))) + .addEqualityGroup( + newRequestBuilder(Object.class) + .error(newRequestBuilder(Object.class).load(firstModel).centerCrop()), + newRequestBuilder(Object.class) + .error(newRequestBuilder(Object.class).load(firstModel).centerCrop())) + .addEqualityGroup( + newRequestBuilder(Object.class) + .thumbnail(newRequestBuilder(Object.class).load(firstModel)), + newRequestBuilder(Object.class) + .thumbnail(newRequestBuilder(Object.class).load(firstModel))) + .addEqualityGroup( + newRequestBuilder(Object.class) + .thumbnail(newRequestBuilder(Object.class).load(secondModel)), + newRequestBuilder(Object.class) + .thumbnail(newRequestBuilder(Object.class).load(secondModel))) + .addEqualityGroup( + newRequestBuilder(Object.class) + .transition(new GenericTransitionOptions<>().dontTransition()), + newRequestBuilder(Object.class) + .transition(new GenericTransitionOptions<>().dontTransition())) + .testEquals(); + } + private RequestBuilder getNullModelRequest() { + return newRequestBuilder(Object.class).load((Object) null); + } + + private RequestBuilder newRequestBuilder(Class modelClass) { when(glideContext.buildImageViewTarget(isA(ImageView.class), isA(Class.class))) .thenReturn(mock(ViewTarget.class)); when(glideContext.getDefaultRequestOptions()).thenReturn(new RequestOptions()); when(requestManager.getDefaultRequestOptions()).thenReturn(new RequestOptions()); when(requestManager.getDefaultTransitionOptions(any(Class.class))) .thenReturn(new GenericTransitionOptions<>()); - return new RequestBuilder<>(glide, requestManager, Object.class, context).load((Object) null); + return new RequestBuilder<>(glide, requestManager, modelClass, context); } } diff --git a/library/test/src/test/java/com/bumptech/glide/RequestManagerTest.java b/library/test/src/test/java/com/bumptech/glide/RequestManagerTest.java index 2c1cbd26bb..b1bf1dd9c3 100644 --- a/library/test/src/test/java/com/bumptech/glide/RequestManagerTest.java +++ b/library/test/src/test/java/com/bumptech/glide/RequestManagerTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.bumptech.glide.tests.BackgroundUtil.testInBackground; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -9,7 +10,6 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; import android.app.Application; import android.content.Context; @@ -26,7 +26,6 @@ import com.bumptech.glide.request.target.CustomTarget; import com.bumptech.glide.request.transition.Transition; import com.bumptech.glide.tests.BackgroundUtil; -import com.bumptech.glide.tests.GlideShadowLooper; import com.bumptech.glide.tests.TearDownGlide; import java.io.File; import java.util.Collections; @@ -42,11 +41,9 @@ import org.mockito.stubbing.Answer; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; -import org.robolectric.annotation.LooperMode; -@LooperMode(LEGACY) @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18, shadows = GlideShadowLooper.class) +@Config(sdk = ROBOLECTRIC_SDK) public class RequestManagerTest { @Rule public TearDownGlide tearDownGlide = new TearDownGlide(); diff --git a/library/test/src/test/java/com/bumptech/glide/load/ImageHeaderParserUtilsTest.java b/library/test/src/test/java/com/bumptech/glide/load/ImageHeaderParserUtilsTest.java new file mode 100644 index 0000000000..cd41760489 --- /dev/null +++ b/library/test/src/test/java/com/bumptech/glide/load/ImageHeaderParserUtilsTest.java @@ -0,0 +1,246 @@ +package com.bumptech.glide.load; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assume.assumeTrue; + +import android.content.Context; +import android.os.ParcelFileDescriptor; +import androidx.annotation.NonNull; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.bumptech.glide.load.data.ParcelFileDescriptorRewinder; +import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; +import com.bumptech.glide.load.engine.bitmap_recycle.LruArrayPool; +import com.bumptech.glide.util.ByteBufferUtil; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class ImageHeaderParserUtilsTest { + private final List fakeParsers = + Arrays.asList(new FakeImageHeaderParser(), new FakeImageHeaderParser()); + private List parsers; + private final Context context = ApplicationProvider.getApplicationContext(); + private final byte[] expectedData = new byte[] {0, 1, 2, 3, 4, 5, 6, 7, 8}; + private final LruArrayPool lruArrayPool = new LruArrayPool(); + + @Before + public void setUp() { + parsers = new ArrayList(); + for (FakeImageHeaderParser parser : fakeParsers) { + parsers.add(parser); + } + } + + @Test + public void getType_withTwoParsers_andStream_rewindsBeforeEachParser() throws IOException { + ImageHeaderParserUtils.getType(parsers, new ByteArrayInputStream(expectedData), lruArrayPool); + + assertAllParsersReceivedTheSameData(); + } + + @Test + public void getType_withTwoParsers_andByteBuffer_rewindsBeforeEachParser() throws IOException { + ImageHeaderParserUtils.getType(parsers, ByteBuffer.wrap(expectedData)); + + assertAllParsersReceivedTheSameData(); + } + + @Test + public void getType_withTwoParsers_andFileDescriptor_rewindsBeforeEachParser() + throws IOException { + // This test can't work if file descriptor rewinding isn't supported. Sadly that means this + // test doesn't work in Robolectric. + assumeTrue(ParcelFileDescriptorRewinder.isSupported()); + + ParcelFileDescriptor fileDescriptor = null; + try { + fileDescriptor = asFileDescriptor(expectedData); + ParcelFileDescriptorRewinder rewinder = new ParcelFileDescriptorRewinder(fileDescriptor); + ImageHeaderParserUtils.getType(parsers, rewinder, lruArrayPool); + } finally { + if (fileDescriptor != null) { + fileDescriptor.close(); + } + } + + assertAllParsersReceivedTheSameData(); + } + + @Test + public void getOrientation_withTwoParsers_andStream_rewindsBeforeEachParser() throws IOException { + ImageHeaderParserUtils.getOrientation( + parsers, new ByteArrayInputStream(expectedData), lruArrayPool); + + assertAllParsersReceivedTheSameData(); + } + + @Test + public void getOrientation_withTwoParsers_andByteBuffer_rewindsBeforeEachParser() + throws IOException { + ImageHeaderParserUtils.getOrientation(parsers, ByteBuffer.wrap(expectedData), lruArrayPool); + + assertAllParsersReceivedTheSameData(); + } + + @Test + public void getOrientation_withTwoParsers_andFileDescriptor_rewindsBeforeEachParser() + throws IOException { + // This test can't work if file descriptor rewinding isn't supported. Sadly that means this + // test doesn't work in Robolectric. + assumeTrue(ParcelFileDescriptorRewinder.isSupported()); + ParcelFileDescriptor fileDescriptor = null; + try { + fileDescriptor = asFileDescriptor(expectedData); + ParcelFileDescriptorRewinder rewinder = new ParcelFileDescriptorRewinder(fileDescriptor); + ImageHeaderParserUtils.getOrientation(parsers, rewinder, lruArrayPool); + } finally { + if (fileDescriptor != null) { + fileDescriptor.close(); + } + } + + assertAllParsersReceivedTheSameData(); + } + + @Test + public void hasJpegMpf_withTwoParsers_andStream_rewindsBeforeEachParser() throws IOException { + ImageHeaderParserUtils.hasJpegMpf( + parsers, new ByteArrayInputStream(expectedData), lruArrayPool); + + assertAllParsersReceivedTheSameData(); + } + + @Test + public void hasJpegMpf_withTwoParsers_andByteBuffer_rewindsBeforeEachParser() throws IOException { + ImageHeaderParserUtils.hasJpegMpf(parsers, ByteBuffer.wrap(expectedData), lruArrayPool); + + assertAllParsersReceivedTheSameData(); + } + + @Test + public void hasJpegMpf_withTwoParsers_andFileDescriptor_rewindsBeforeEachParser() + throws IOException { + // This test can't work if file descriptor rewinding isn't supported. Sadly that means this + // test doesn't work in Robolectric. + assumeTrue(ParcelFileDescriptorRewinder.isSupported()); + ParcelFileDescriptor fileDescriptor = null; + try { + fileDescriptor = asFileDescriptor(expectedData); + ParcelFileDescriptorRewinder rewinder = new ParcelFileDescriptorRewinder(fileDescriptor); + ImageHeaderParserUtils.hasJpegMpf(parsers, rewinder, lruArrayPool); + } finally { + if (fileDescriptor != null) { + fileDescriptor.close(); + } + } + + assertAllParsersReceivedTheSameData(); + } + + private void assertAllParsersReceivedTheSameData() { + for (FakeImageHeaderParser parser : fakeParsers) { + assertThat(parser.data).isNotNull(); + assertThat(parser.data).asList().containsExactlyElementsIn(asList(expectedData)).inOrder(); + } + } + + private static List asList(byte[] data) { + List result = new ArrayList<>(); + for (byte item : data) { + result.add(item); + } + return result; + } + + private ParcelFileDescriptor asFileDescriptor(byte[] data) throws IOException { + File file = new File(context.getCacheDir(), "temp"); + OutputStream os = null; + try { + os = new FileOutputStream(file); + os.write(data); + os.close(); + } finally { + if (os != null) { + os.close(); + } + } + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); + } + + private static final class FakeImageHeaderParser implements ImageHeaderParser { + + private byte[] data; + + private void readData(InputStream is) throws IOException { + readData(ByteBufferUtil.fromStream(is)); + } + + // This is rather roundabout, but it's a simple way of reading the remaining data in the buffer. + private void readData(ByteBuffer byteBuffer) { + + byte[] data = new byte[byteBuffer.remaining()]; + // A 0 length means we read no data. If we try to pass this to ByteBuffer it will throw. We'd + // rather not get that obscure exception and instead have an assertion above trigger because + // we didn't read enough data. So we work around the exception here if we have no data to + // read. + if (data.length != 0) { + byteBuffer.get(data, byteBuffer.position(), byteBuffer.remaining()); + } + this.data = data; + } + + @NonNull + @Override + public ImageType getType(@NonNull InputStream is) throws IOException { + readData(is); + return ImageType.UNKNOWN; + } + + @NonNull + @Override + public ImageType getType(@NonNull ByteBuffer byteBuffer) throws IOException { + readData(byteBuffer); + return ImageType.UNKNOWN; + } + + @Override + public int getOrientation(@NonNull InputStream is, @NonNull ArrayPool byteArrayPool) + throws IOException { + readData(is); + return ImageHeaderParser.UNKNOWN_ORIENTATION; + } + + @Override + public int getOrientation(@NonNull ByteBuffer byteBuffer, @NonNull ArrayPool byteArrayPool) + throws IOException { + readData(byteBuffer); + return ImageHeaderParser.UNKNOWN_ORIENTATION; + } + + @Override + public boolean hasJpegMpf(@NonNull InputStream is, @NonNull ArrayPool byteArrayPool) + throws IOException { + readData(is); + return false; + } + + @Override + public boolean hasJpegMpf(@NonNull ByteBuffer byteBuffer, @NonNull ArrayPool byteArrayPool) + throws IOException { + readData(byteBuffer); + return false; + } + } +} diff --git a/library/test/src/test/java/com/bumptech/glide/load/MultiTransformationTest.java b/library/test/src/test/java/com/bumptech/glide/load/MultiTransformationTest.java index e2fbbc1904..e875a3258d 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/MultiTransformationTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/MultiTransformationTest.java @@ -13,6 +13,8 @@ import static org.mockito.Mockito.when; import android.app.Application; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; import com.bumptech.glide.load.engine.Resource; import com.bumptech.glide.tests.KeyTester; import com.bumptech.glide.tests.Util; @@ -22,12 +24,10 @@ import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.robolectric.RuntimeEnvironment; -@RunWith(JUnit4.class) +@RunWith(AndroidJUnit4.class) @SuppressWarnings("unchecked") public class MultiTransformationTest { @Rule public final KeyTester keyTester = new KeyTester(); @@ -43,7 +43,7 @@ public class MultiTransformationTest { public void setUp() { MockitoAnnotations.initMocks(this); - context = RuntimeEnvironment.application; + context = ApplicationProvider.getApplicationContext(); doAnswer(new Util.WriteDigest("first")) .when(first) diff --git a/library/test/src/test/java/com/bumptech/glide/load/OptionsTest.java b/library/test/src/test/java/com/bumptech/glide/load/OptionsTest.java index cf96d4bb28..85419ff5ce 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/OptionsTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/OptionsTest.java @@ -1,5 +1,7 @@ package com.bumptech.glide.load; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; + import androidx.annotation.NonNull; import com.bumptech.glide.load.Option.CacheKeyUpdater; import com.bumptech.glide.tests.KeyTester; @@ -12,7 +14,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class OptionsTest { @Rule public final KeyTester keyTester = new KeyTester(); diff --git a/library/test/src/test/java/com/bumptech/glide/load/data/BufferedOutputStreamFuzzTest.java b/library/test/src/test/java/com/bumptech/glide/load/data/BufferedOutputStreamFuzzTest.java index 2089a9dd97..a3445af4d2 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/data/BufferedOutputStreamFuzzTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/data/BufferedOutputStreamFuzzTest.java @@ -133,13 +133,13 @@ private Write getOffsetBufferWrite(Random random) { private Write getBufferWrite(Random random) { byte[] data = new byte[random.nextInt(MAX_BYTES_PER_WRITE)]; random.nextBytes(data); - return new Write(data, /*length=*/ data.length, /*offset=*/ 0, WriteType.BUFFER); + return new Write(data, /* length= */ data.length, /* offset= */ 0, WriteType.BUFFER); } private Write getByteWrite(Random random) { byte[] data = new byte[1]; random.nextBytes(data); - return new Write(data, /*length=*/ 1, /*offset=*/ 0, WriteType.BYTE); + return new Write(data, /* length= */ 1, /* offset= */ 0, WriteType.BYTE); } private WriteType getType(Random random) { diff --git a/library/test/src/test/java/com/bumptech/glide/load/data/BufferedOutputStreamTest.java b/library/test/src/test/java/com/bumptech/glide/load/data/BufferedOutputStreamTest.java index 2d92187226..932cb4cf22 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/data/BufferedOutputStreamTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/data/BufferedOutputStreamTest.java @@ -804,7 +804,7 @@ public void write_throwsIfOffsetIsLessThanZero() { new ThrowingRunnable() { @Override public void run() throws Throwable { - os.write(new byte[0], /*initialOffset=*/ -1, /*length=*/ 0); + os.write(new byte[0], /* initialOffset= */ -1, /* length= */ 0); } }); } @@ -816,7 +816,7 @@ public void write_throwsIfLengthIsLessThanZero() { new ThrowingRunnable() { @Override public void run() throws Throwable { - os.write(new byte[0], /*initialOffset=*/ 0, /*length=*/ -1); + os.write(new byte[0], /* initialOffset= */ 0, /* length= */ -1); } }); } @@ -828,7 +828,7 @@ public void write_throwsIfOffsetIsGreaterThanLength() { new ThrowingRunnable() { @Override public void run() throws Throwable { - os.write(new byte[0], /*initialOffset=*/ 1, /*length=*/ 0); + os.write(new byte[0], /* initialOffset= */ 1, /* length= */ 0); } }); } @@ -840,7 +840,7 @@ public void write_throwsIfLengthsIsGreaterThanLength() { new ThrowingRunnable() { @Override public void run() throws Throwable { - os.write(new byte[0], /*initialOffset=*/ 0, /*length=*/ 1); + os.write(new byte[0], /* initialOffset= */ 0, /* length= */ 1); } }); } @@ -852,7 +852,7 @@ public void write_throwsIfLengthAndOffsetsIsGreaterThanLength() { new ThrowingRunnable() { @Override public void run() throws Throwable { - os.write(new byte[1], /*initialOffset=*/ 1, /*length=*/ 1); + os.write(new byte[1], /* initialOffset= */ 1, /* length= */ 1); } }); } diff --git a/library/test/src/test/java/com/bumptech/glide/load/data/ExifOrientationStreamTest.java b/library/test/src/test/java/com/bumptech/glide/load/data/ExifOrientationStreamTest.java index 1183068070..36cdb2baad 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/data/ExifOrientationStreamTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/data/ExifOrientationStreamTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.data; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; @@ -15,7 +16,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class ExifOrientationStreamTest { private ArrayPool byteArrayPool; diff --git a/library/test/src/test/java/com/bumptech/glide/load/data/FileDescriptorAssetPathFetcherTest.java b/library/test/src/test/java/com/bumptech/glide/load/data/FileDescriptorAssetPathFetcherTest.java index 3204525d8f..4b19156135 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/data/FileDescriptorAssetPathFetcherTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/data/FileDescriptorAssetPathFetcherTest.java @@ -1,14 +1,13 @@ package com.bumptech.glide.load.data; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import android.content.res.AssetFileDescriptor; import android.content.res.AssetManager; -import android.os.ParcelFileDescriptor; import com.bumptech.glide.Priority; import java.io.IOException; import org.junit.Before; @@ -20,30 +19,27 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class FileDescriptorAssetPathFetcherTest { @Mock private AssetManager assetManager; @Mock private AssetFileDescriptor assetFileDescriptor; - @Mock private DataFetcher.DataCallback callback; + @Mock private DataFetcher.DataCallback callback; private FileDescriptorAssetPathFetcher fetcher; - private ParcelFileDescriptor expected; @Before public void setUp() throws IOException { MockitoAnnotations.initMocks(this); String assetPath = "/some/asset/path"; fetcher = new FileDescriptorAssetPathFetcher(assetManager, assetPath); - expected = mock(ParcelFileDescriptor.class); - when(assetFileDescriptor.getParcelFileDescriptor()).thenReturn(expected); when(assetManager.openFd(eq(assetPath))).thenReturn(assetFileDescriptor); } @Test public void testOpensInputStreamForPathWithAssetManager() throws Exception { fetcher.loadData(Priority.NORMAL, callback); - verify(callback).onDataReady(eq(expected)); + verify(callback).onDataReady(eq(assetFileDescriptor)); } @Test @@ -51,19 +47,19 @@ public void testClosesOpenedInputStreamOnCleanup() throws Exception { fetcher.loadData(Priority.NORMAL, callback); fetcher.cleanup(); - verify(expected).close(); + verify(assetFileDescriptor).close(); } @Test public void testDoesNothingOnCleanupIfNoDataLoaded() throws IOException { fetcher.cleanup(); - verify(expected, never()).close(); + verify(assetFileDescriptor, never()).close(); } @Test public void testDoesNothingOnCancel() throws Exception { fetcher.loadData(Priority.NORMAL, callback); fetcher.cancel(); - verify(expected, never()).close(); + verify(assetFileDescriptor, never()).close(); } } diff --git a/library/test/src/test/java/com/bumptech/glide/load/data/HttpUrlFetcherServerTest.java b/library/test/src/test/java/com/bumptech/glide/load/data/HttpUrlFetcherServerTest.java index bf5ff27fe7..4366e6144b 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/data/HttpUrlFetcherServerTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/data/HttpUrlFetcherServerTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.data; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.mock; @@ -36,7 +37,7 @@ * com.bumptech.glide.load.data.HttpUrlFetcherTest}, response handling should go here. */ @RunWith(RobolectricTestRunner.class) -@Config(manifest = Config.NONE, sdk = 18) +@Config(manifest = Config.NONE, sdk = ROBOLECTRIC_SDK) public class HttpUrlFetcherServerTest { private static final String DEFAULT_PATH = "/fakepath"; private static final int TIMEOUT_TIME_MS = 300; diff --git a/library/test/src/test/java/com/bumptech/glide/load/data/HttpUrlFetcherTest.java b/library/test/src/test/java/com/bumptech/glide/load/data/HttpUrlFetcherTest.java index a471dc4534..8922fc6426 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/data/HttpUrlFetcherTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/data/HttpUrlFetcherTest.java @@ -1,8 +1,8 @@ package com.bumptech.glide.load.data; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; @@ -23,6 +23,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -30,7 +31,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class HttpUrlFetcherTest { @Mock private HttpURLConnection urlConnection; @Mock private HttpUrlFetcher.HttpUrlConnectionFactory connectionFactory; @@ -133,7 +134,7 @@ public void testReturnsNullIfCancelledBeforeConnects() throws IOException { fetcher.cancel(); fetcher.loadData(Priority.LOW, callback); - verify(callback).onDataReady(isNull(InputStream.class)); + verify(callback).onDataReady(ArgumentMatchers.isNull()); } @Test diff --git a/library/test/src/test/java/com/bumptech/glide/load/data/LocalUriFetcherTest.java b/library/test/src/test/java/com/bumptech/glide/load/data/LocalUriFetcherTest.java index ea33c89f1f..01557e7746 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/data/LocalUriFetcherTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/data/LocalUriFetcherTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.data; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -23,7 +24,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class LocalUriFetcherTest { private TestLocalUriFetcher fetcher; @Mock private DataFetcher.DataCallback callback; diff --git a/library/test/src/test/java/com/bumptech/glide/load/data/StreamAssetPathFetcherTest.java b/library/test/src/test/java/com/bumptech/glide/load/data/StreamAssetPathFetcherTest.java index 36acc7e96c..23fe4d38c1 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/data/StreamAssetPathFetcherTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/data/StreamAssetPathFetcherTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.data; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -18,7 +19,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class StreamAssetPathFetcherTest { @Mock private AssetManager assetManager; @Mock private InputStream expected; diff --git a/library/test/src/test/java/com/bumptech/glide/load/data/mediastore/MediaStoreUtilTest.java b/library/test/src/test/java/com/bumptech/glide/load/data/mediastore/MediaStoreUtilTest.java new file mode 100644 index 0000000000..00c293ff63 --- /dev/null +++ b/library/test/src/test/java/com/bumptech/glide/load/data/mediastore/MediaStoreUtilTest.java @@ -0,0 +1,64 @@ +package com.bumptech.glide.load.data.mediastore; + +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import android.provider.MediaStore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = ROBOLECTRIC_SDK) +public class MediaStoreUtilTest { + + @Test + public void isAndroidPickerUri_notAndroidPickerUri_returnsFalse() { + Uri mediaStoreUri = Uri.withAppendedPath(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, "123"); + + assertThat(MediaStoreUtil.isAndroidPickerUri(mediaStoreUri)).isFalse(); + } + + @Test + public void isAndroidPickerUri_identifiesAndroidPickerUri_returnsTrue() { + Uri androidPickerUri = + Uri.parse("content://media/picker/0/com.android.providers.media.photopicker/media/123"); + + assertThat(MediaStoreUtil.isAndroidPickerUri(androidPickerUri)).isTrue(); + + Uri androidPickerGetContentUri = + Uri.parse( + "content://media/picker_get_content/0/com.android.providers.media.photopicker/media/123"); + + assertThat(MediaStoreUtil.isAndroidPickerUri(androidPickerGetContentUri)).isTrue(); + + Uri androidPickerTranscodedUri = + Uri.parse( + "content://media/picker_transcoded/0/com.android.providers.media.photopicker/media/123"); + + assertThat(MediaStoreUtil.isAndroidPickerUri(androidPickerTranscodedUri)).isTrue(); + + Uri androidModifiedPickerUri = + Uri.parse( + "content://media/picker.component1-true.component2:" + + " false}/0/com.android.providers.media.photopicker/media/123"); + + assertThat(MediaStoreUtil.isAndroidPickerUri(androidModifiedPickerUri)).isTrue(); + + Uri androidModifiedPickerGetContentUri = + Uri.parse( + "content://media/picker_get_content.component1-true.component2:" + + " false}/0/com.android.providers.media.photopicker/media/123"); + + assertThat(MediaStoreUtil.isAndroidPickerUri(androidModifiedPickerGetContentUri)).isTrue(); + + Uri androidModifiedPickerTranscodedUri = + Uri.parse( + "content://media/picker_transcoded.component1-true.component2-xxxx" + + " false}/0/com.android.providers.media.photopicker/media/123"); + + assertThat(MediaStoreUtil.isAndroidPickerUri(androidModifiedPickerTranscodedUri)).isTrue(); + } +} diff --git a/library/test/src/test/java/com/bumptech/glide/load/data/mediastore/ThumbFetcherTest.java b/library/test/src/test/java/com/bumptech/glide/load/data/mediastore/ThumbFetcherTest.java index 794e3cb965..eac904eb5e 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/data/mediastore/ThumbFetcherTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/data/mediastore/ThumbFetcherTest.java @@ -1,7 +1,7 @@ package com.bumptech.glide.load.data.mediastore; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNotNull; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -13,13 +13,14 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class ThumbFetcherTest { @Mock private ThumbnailStreamOpener opener; @@ -42,7 +43,7 @@ public void testReturnsInputStreamFromThumbnailOpener() throws Exception { when(opener.open(eq(uri))).thenReturn(expected); fetcher.loadData(Priority.LOW, callback); - verify(callback).onDataReady(isNotNull(InputStream.class)); + verify(callback).onDataReady(ArgumentMatchers.isNotNull()); } @Test diff --git a/library/test/src/test/java/com/bumptech/glide/load/data/mediastore/ThumbnailStreamOpenerTest.java b/library/test/src/test/java/com/bumptech/glide/load/data/mediastore/ThumbnailStreamOpenerTest.java index 344d02f994..bdda77a451 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/data/mediastore/ThumbnailStreamOpenerTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/data/mediastore/ThumbnailStreamOpenerTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.data.mediastore; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -26,7 +27,9 @@ import java.util.ArrayList; import java.util.List; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.Shadows; @@ -34,12 +37,14 @@ import org.robolectric.fakes.RoboCursor; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class ThumbnailStreamOpenerTest { private Harness harness; + @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Before - public void setUp() { + public void setUp() throws Exception { harness = new Harness(); } @@ -123,15 +128,15 @@ private static ContentResolver getContentResolver() { return ApplicationProvider.getApplicationContext().getContentResolver(); } - private static class Harness { + private class Harness { final MatrixCursor cursor = new MatrixCursor(new String[1]); - final File file = new File("fake/uri"); + final File file = temporaryFolder.newFile(); final Uri uri = Uri.fromFile(file); final ThumbnailQuery query = mock(ThumbnailQuery.class); final FileService service = mock(FileService.class); final ArrayPool byteArrayPool = new LruArrayPool(); - Harness() { + Harness() throws Exception { cursor.addRow(new String[] {file.getAbsolutePath()}); when(query.query(eq(uri))).thenReturn(cursor); when(service.get(eq(file.getAbsolutePath()))).thenReturn(file); diff --git a/library/test/src/test/java/com/bumptech/glide/load/data/resource/FileDescriptorLocalUriFetcherTest.java b/library/test/src/test/java/com/bumptech/glide/load/data/resource/FileDescriptorLocalUriFetcherTest.java index ea32cd4376..c21129f4bc 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/data/resource/FileDescriptorLocalUriFetcherTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/data/resource/FileDescriptorLocalUriFetcherTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.data.resource; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.mock; @@ -15,12 +16,15 @@ import com.bumptech.glide.Priority; import com.bumptech.glide.load.data.DataFetcher; import com.bumptech.glide.load.data.FileDescriptorLocalUriFetcher; +import com.bumptech.glide.load.data.mediastore.MediaStoreUtil; import com.bumptech.glide.tests.ContentResolverShadow; import java.io.FileNotFoundException; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; @@ -28,7 +32,7 @@ @RunWith(RobolectricTestRunner.class) @Config( - sdk = 18, + sdk = ROBOLECTRIC_SDK, shadows = {ContentResolverShadow.class}) public class FileDescriptorLocalUriFetcherTest { @@ -53,11 +57,37 @@ public void testLoadResource_returnsFileDescriptor() throws Exception { shadow.registerFileDescriptor(uri, assetFileDescriptor); FileDescriptorLocalUriFetcher fetcher = - new FileDescriptorLocalUriFetcher(context.getContentResolver(), uri); + new FileDescriptorLocalUriFetcher(context.getContentResolver(), uri, false); fetcher.loadData(Priority.NORMAL, callback); verify(callback).onDataReady(eq(parcelFileDescriptor)); } + @Test + public void testLoadResource_mediaUri_returnsFileDescriptor() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + Uri uri = Uri.parse("content://media"); + + ContentResolver contentResolver = context.getContentResolver(); + + AssetFileDescriptor assetFileDescriptor = mock(AssetFileDescriptor.class); + ParcelFileDescriptor parcelFileDescriptor = mock(ParcelFileDescriptor.class); + when(assetFileDescriptor.getParcelFileDescriptor()).thenReturn(parcelFileDescriptor); + + FileDescriptorLocalUriFetcher fetcher = + new FileDescriptorLocalUriFetcher( + context.getContentResolver(), uri, /* useMediaStoreApisIfAvailable */ true); + + try (MockedStatic utils = Mockito.mockStatic(MediaStoreUtil.class)) { + utils.when(MediaStoreUtil::isMediaStoreOpenFileApisAvailable).thenReturn(true); + utils.when(() -> MediaStoreUtil.isMediaStoreUri(uri)).thenReturn(true); + utils + .when(() -> MediaStoreUtil.openAssetFileDescriptor(uri, contentResolver)) + .thenReturn(assetFileDescriptor); + fetcher.loadData(Priority.NORMAL, callback); + verify(callback).onDataReady(eq(parcelFileDescriptor)); + } + } + @Test public void testLoadResource_withNullFileDescriptor_callsLoadFailed() { Context context = ApplicationProvider.getApplicationContext(); @@ -68,7 +98,8 @@ public void testLoadResource_withNullFileDescriptor_callsLoadFailed() { shadow.registerFileDescriptor(uri, null /*fileDescriptor*/); FileDescriptorLocalUriFetcher fetcher = - new FileDescriptorLocalUriFetcher(context.getContentResolver(), uri); + new FileDescriptorLocalUriFetcher( + context.getContentResolver(), uri, /* useMediaStoreApisIfAvailable */ false); fetcher.loadData(Priority.NORMAL, callback); verify(callback).onLoadFailed(isA(FileNotFoundException.class)); } diff --git a/library/test/src/test/java/com/bumptech/glide/load/data/resource/StreamLocalUriFetcherTest.java b/library/test/src/test/java/com/bumptech/glide/load/data/resource/StreamLocalUriFetcherTest.java index 3b51ca189b..f70b7ec708 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/data/resource/StreamLocalUriFetcherTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/data/resource/StreamLocalUriFetcherTest.java @@ -1,24 +1,33 @@ package com.bumptech.glide.load.data.resource; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; -import static org.mockito.ArgumentMatchers.isNotNull; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import android.content.ContentResolver; import android.content.Context; +import android.content.res.AssetFileDescriptor; import android.net.Uri; import androidx.test.core.app.ApplicationProvider; import com.bumptech.glide.Priority; import com.bumptech.glide.load.data.DataFetcher; import com.bumptech.glide.load.data.StreamLocalUriFetcher; +import com.bumptech.glide.load.data.mediastore.MediaStoreUtil; import com.bumptech.glide.tests.ContentResolverShadow; import java.io.ByteArrayInputStream; +import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.InputStream; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; @@ -26,7 +35,7 @@ @RunWith(RobolectricTestRunner.class) @Config( - sdk = 18, + sdk = ROBOLECTRIC_SDK, shadows = {ContentResolverShadow.class}) public class StreamLocalUriFetcherTest { @Mock private DataFetcher.DataCallback callback; @@ -45,9 +54,36 @@ public void testLoadResource_returnsInputStream() throws Exception { ContentResolverShadow shadow = Shadow.extract(contentResolver); shadow.registerInputStream(uri, new ByteArrayInputStream(new byte[0])); - StreamLocalUriFetcher fetcher = new StreamLocalUriFetcher(context.getContentResolver(), uri); + StreamLocalUriFetcher fetcher = + new StreamLocalUriFetcher(context.getContentResolver(), uri, false); fetcher.loadData(Priority.NORMAL, callback); - verify(callback).onDataReady(isNotNull(InputStream.class)); + verify(callback).onDataReady(ArgumentMatchers.isNotNull()); + } + + @Test + public void testLoadResource_mediaUri_returnsFileDescriptor() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + Uri uri = Uri.parse("content://media"); + + ContentResolver contentResolver = context.getContentResolver(); + + AssetFileDescriptor assetFileDescriptor = mock(AssetFileDescriptor.class); + FileInputStream inputStream = mock(FileInputStream.class); + when(assetFileDescriptor.createInputStream()).thenReturn(inputStream); + + StreamLocalUriFetcher fetcher = + new StreamLocalUriFetcher( + context.getContentResolver(), uri, /* useMediaStoreApisIfAvailable */ true); + + try (MockedStatic utils = Mockito.mockStatic(MediaStoreUtil.class)) { + utils.when(MediaStoreUtil::isMediaStoreOpenFileApisAvailable).thenReturn(true); + utils.when(() -> MediaStoreUtil.isMediaStoreUri(uri)).thenReturn(true); + utils + .when(() -> MediaStoreUtil.openAssetFileDescriptor(uri, contentResolver)) + .thenReturn(assetFileDescriptor); + fetcher.loadData(Priority.NORMAL, callback); + verify(callback).onDataReady(eq(inputStream)); + } } @Test @@ -60,7 +96,9 @@ public void testLoadResource_withNullInputStream_callsLoadFailed() { shadow.registerInputStream(uri, null /*inputStream*/); - StreamLocalUriFetcher fetcher = new StreamLocalUriFetcher(context.getContentResolver(), uri); + StreamLocalUriFetcher fetcher = + new StreamLocalUriFetcher( + context.getContentResolver(), uri, /* useMediaStoreApisIfAvailable */ false); fetcher.loadData(Priority.LOW, callback); verify(callback).onLoadFailed(isA(FileNotFoundException.class)); diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/ActiveResourcesTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/ActiveResourcesTest.java index e5ac06c672..96a70eae2f 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/ActiveResourcesTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/ActiveResourcesTest.java @@ -4,9 +4,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.reset; import static org.mockito.Mockito.verify; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; import android.os.Looper; import androidx.annotation.NonNull; @@ -14,7 +12,6 @@ import com.bumptech.glide.load.engine.ActiveResources.DequeuedResourceCallback; import com.bumptech.glide.load.engine.ActiveResources.ResourceWeakReference; import com.bumptech.glide.load.engine.EngineResource.ResourceListener; -import com.bumptech.glide.tests.GlideShadowLooper; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; @@ -29,12 +26,8 @@ import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.Shadows; -import org.robolectric.annotation.Config; -import org.robolectric.annotation.LooperMode; -@LooperMode(LEGACY) @RunWith(RobolectricTestRunner.class) -@Config(shadows = GlideShadowLooper.class) public class ActiveResourcesTest { @Mock private ResourceListener listener; @@ -46,10 +39,8 @@ public class ActiveResourcesTest { @Before public void setUp() { MockitoAnnotations.initMocks(this); - resources = new ActiveResources(/*isActiveResourceRetentionAllowed=*/ true); + resources = new ActiveResources(/* isActiveResourceRetentionAllowed= */ true); resources.setListener(listener); - - reset(GlideShadowLooper.queue); } @After @@ -249,7 +240,7 @@ public void queueIdle_withQueuedReferenceDeactivated_doesNotNotifyListener() { final CountDownLatch blockExecutor = new CountDownLatch(1); resources = new ActiveResources( - /*isActiveResourceRetentionAllowed=*/ true, + /* isActiveResourceRetentionAllowed= */ true, new Executor() { @Override public void execute(@NonNull final Runnable command) { @@ -294,7 +285,7 @@ public void queueIdle_afterReferenceQueuedThenReactivated_doesNotNotifyListener( final CountDownLatch blockExecutor = new CountDownLatch(1); resources = new ActiveResources( - /*isActiveResourceRetentionAllowed=*/ true, + /* isActiveResourceRetentionAllowed= */ true, new Executor() { @Override public void execute(@NonNull final Runnable command) { @@ -344,7 +335,7 @@ public void activate_withNonCacheableResource_doesNotSaveResource() { @Test public void get_withActiveClearedKey_cacheableResource_retentionDisabled_doesNotCallListener() { - resources = new ActiveResources(/*isActiveResourceRetentionAllowed=*/ false); + resources = new ActiveResources(/* isActiveResourceRetentionAllowed= */ false); resources.setListener(listener); EngineResource engineResource = newCacheableEngineResource(); resources.activate(key, engineResource); @@ -356,7 +347,7 @@ public void get_withActiveClearedKey_cacheableResource_retentionDisabled_doesNot @Test public void queueIdle_withQueuedReferenceRetrievedFromGet_retentionDisabled_doesNotNotify() { - resources = new ActiveResources(/*isActiveResourceRetentionAllowed=*/ false); + resources = new ActiveResources(/* isActiveResourceRetentionAllowed= */ false); resources.setListener(listener); EngineResource engineResource = newCacheableEngineResource(); resources.activate(key, engineResource); @@ -401,12 +392,12 @@ public void onResourceDequeued() { private EngineResource newCacheableEngineResource() { return new EngineResource<>( - resource, /*isMemoryCacheable=*/ true, /*isRecyclable=*/ false, key, listener); + resource, /* isMemoryCacheable= */ true, /* isRecyclable= */ false, key, listener); } private EngineResource newNonCacheableEngineResource() { return new EngineResource<>( - resource, /*isMemoryCacheable=*/ false, /*isRecyclable=*/ false, key, listener); + resource, /* isMemoryCacheable= */ false, /* isRecyclable= */ false, key, listener); } @SuppressWarnings("unchecked") diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/EngineJobTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/EngineJobTest.java index 630e0e1303..9229d600e9 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/EngineJobTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/EngineJobTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.engine; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.bumptech.glide.tests.Util.anyResource; import static com.bumptech.glide.tests.Util.isADataSource; import static com.bumptech.glide.tests.Util.mockResource; @@ -8,7 +9,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -32,6 +32,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentMatchers; import org.mockito.InOrder; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; @@ -41,7 +42,7 @@ import org.robolectric.shadows.ShadowLooper; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class EngineJobTest { private EngineJobHarness harness; @@ -121,7 +122,8 @@ public void testListenerNotifiedJobCompleteOnException() { job.onLoadFailed(new GlideException("test")); ShadowLooper.runUiThreadTasks(); verify(harness.engineJobListener) - .onEngineJobComplete(eq(job), eq(harness.key), isNull(EngineResource.class)); + .onEngineJobComplete( + eq(job), eq(harness.key), ArgumentMatchers.>isNull()); } @Test @@ -244,7 +246,8 @@ public void testDoesNotNotifyCancelledIfReceivedException() { job.onLoadFailed(new GlideException("test")); verify(harness.engineJobListener) - .onEngineJobComplete(eq(job), eq(harness.key), isNull(EngineResource.class)); + .onEngineJobComplete( + eq(job), eq(harness.key), ArgumentMatchers.>isNull()); verify(harness.engineJobListener, never()) .onEngineJobCancelled(any(EngineJob.class), any(Key.class)); } diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/EngineKeyTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/EngineKeyTest.java index 6ea803c99c..6d82835e50 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/EngineKeyTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/EngineKeyTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.engine; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertThrows; import androidx.annotation.NonNull; @@ -23,7 +24,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class EngineKeyTest { @Mock private Transformation transformation; diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/EngineResourceTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/EngineResourceTest.java index 25bba143a5..660f66fac6 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/EngineResourceTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/EngineResourceTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.engine; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.bumptech.glide.tests.Util.mockResource; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -21,7 +22,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class EngineResourceTest { private EngineResource engineResource; @Mock private EngineResource.ResourceListener listener; @@ -33,7 +34,7 @@ public void setUp() { MockitoAnnotations.initMocks(this); engineResource = new EngineResource<>( - resource, /*isMemoryCacheable=*/ true, /*isRecyclable=*/ true, cacheKey, listener); + resource, /* isMemoryCacheable= */ true, /* isRecyclable= */ true, cacheKey, listener); } @Test @@ -148,7 +149,11 @@ public void testThrowsIfReleasedMoreThanAcquired() { @Test(expected = NullPointerException.class) public void testThrowsIfWrappedResourceIsNull() { new EngineResource<>( - /*toWrap=*/ null, /*isMemoryCacheable=*/ false, /*isRecyclable=*/ true, cacheKey, listener); + /* toWrap= */ null, + /* isMemoryCacheable= */ false, + /* isRecyclable= */ true, + cacheKey, + listener); } @Test @@ -156,16 +161,16 @@ public void testCanSetAndGetIsCacheable() { engineResource = new EngineResource<>( mockResource(), - /*isMemoryCacheable=*/ true, - /*isRecyclable=*/ true, + /* isMemoryCacheable= */ true, + /* isRecyclable= */ true, cacheKey, listener); assertTrue(engineResource.isMemoryCacheable()); engineResource = new EngineResource<>( mockResource(), - /*isMemoryCacheable=*/ false, - /*isRecyclable=*/ true, + /* isMemoryCacheable= */ false, + /* isRecyclable= */ true, cacheKey, listener); assertFalse(engineResource.isMemoryCacheable()); @@ -176,7 +181,7 @@ public void release_whenNotRecycleable_doesNotRecycleResource() { resource = mockResource(); engineResource = new EngineResource<>( - resource, /*isMemoryCacheable=*/ true, /*isRecyclable=*/ false, cacheKey, listener); + resource, /* isMemoryCacheable= */ true, /* isRecyclable= */ false, cacheKey, listener); engineResource.recycle(); verify(listener, never()).onResourceReleased(any(Key.class), any(EngineResource.class)); diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/EngineTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/EngineTest.java index 1dc65fa010..78f89e2bd6 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/EngineTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/EngineTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.engine; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.bumptech.glide.tests.Util.anyResource; import static com.bumptech.glide.tests.Util.isADataSource; import static com.bumptech.glide.tests.Util.mockResource; @@ -17,7 +18,6 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; import com.bumptech.glide.GlideContext; import com.bumptech.glide.Priority; @@ -32,7 +32,6 @@ import com.bumptech.glide.load.engine.executor.MockGlideExecutor; import com.bumptech.glide.request.ResourceCallback; import com.bumptech.glide.tests.BackgroundUtil; -import com.bumptech.glide.tests.GlideShadowLooper; import com.bumptech.glide.util.Executors; import java.util.HashMap; import java.util.Map; @@ -44,13 +43,9 @@ import org.mockito.stubbing.Answer; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; -import org.robolectric.annotation.LooperMode; -@LooperMode(LEGACY) @RunWith(RobolectricTestRunner.class) -@Config( - sdk = 18, - shadows = {GlideShadowLooper.class}) +@Config(sdk = ROBOLECTRIC_SDK) @SuppressWarnings("unchecked") public class EngineTest { private EngineTestHarness harness; @@ -281,7 +276,7 @@ public void testRunnerIsRemovedFromRunnersOnEngineNotifiedJobComplete() { public void testEngineIsNotSetAsResourceListenerIfResourceIsNullOnJobComplete() { harness.doLoad(); - harness.getEngine().onEngineJobComplete(harness.job, harness.cacheKey, /*resource=*/ null); + harness.getEngine().onEngineJobComplete(harness.job, harness.cacheKey, /* resource= */ null); } @Test @@ -295,7 +290,7 @@ public void testResourceIsAddedToActiveResourcesOnEngineComplete() { @Test public void testDoesNotPutNullResourceInActiveResourcesOnEngineComplete() { - harness.getEngine().onEngineJobComplete(harness.job, harness.cacheKey, /*resource=*/ null); + harness.getEngine().onEngineJobComplete(harness.job, harness.cacheKey, /* resource= */ null); assertThat(harness.activeResources.get(harness.cacheKey)).isNull(); } @@ -414,8 +409,8 @@ public void testFactoryIsGivenNecessaryArguments() { eq(harness.cacheKey), eq(true) /*isMemoryCacheable*/, eq(false) /*useUnlimitedSourceGeneratorPool*/, - /*useAnimationPool=*/ eq(false), - /*onlyRetrieveFromCache=*/ eq(false)); + /* useAnimationPool= */ eq(false), + /* onlyRetrieveFromCache= */ eq(false)); } @Test @@ -428,8 +423,8 @@ public void testFactoryIsGivenNecessaryArgumentsWithUnlimitedPool() { eq(harness.cacheKey), eq(true) /*isMemoryCacheable*/, eq(true) /*useUnlimitedSourceGeneratorPool*/, - /*useAnimationPool=*/ eq(false), - /*onlyRetrieveFromCache=*/ eq(false)); + /* useAnimationPool= */ eq(false), + /* onlyRetrieveFromCache= */ eq(false)); } @Test @@ -661,7 +656,7 @@ private static class EngineTestHarness { final Jobs jobs = new Jobs(); final ActiveResources activeResources = - new ActiveResources(/*isActiveResourceRetentionAllowed=*/ true); + new ActiveResources(/* isActiveResourceRetentionAllowed= */ true); final int width = 100; final int height = 100; @@ -724,7 +719,7 @@ Engine.LoadStatus doLoad() { options, isMemoryCacheable, useUnlimitedSourceGeneratorPool, - /*useAnimationPool=*/ false, + /* useAnimationPool= */ false, onlyRetrieveFromCache, cb, Executors.directExecutor()); @@ -746,7 +741,7 @@ Engine getEngine() { engineJobFactory, decodeJobFactory, resourceRecycler, - /*isActiveResourceRetentionAllowed=*/ true); + /* isActiveResourceRetentionAllowed= */ true); } return engine; } diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/ResourceRecyclerTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/ResourceRecyclerTest.java index da172dc103..94767b4d8e 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/ResourceRecyclerTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/ResourceRecyclerTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.engine; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.bumptech.glide.tests.Util.mockResource; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.never; @@ -16,7 +17,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class ResourceRecyclerTest { private ResourceRecycler recycler; @@ -30,7 +31,7 @@ public void setUp() { public void recycle_withoutForceNextFrame_recyclesResourceSynchronously() { Resource resource = mockResource(); Shadows.shadowOf(Looper.getMainLooper()).pause(); - recycler.recycle(resource, /*forceNextFrame=*/ false); + recycler.recycle(resource, /* forceNextFrame= */ false); verify(resource).recycle(); } @@ -38,7 +39,7 @@ public void recycle_withoutForceNextFrame_recyclesResourceSynchronously() { public void recycle_withForceNextFrame_postsRecycle() { Resource resource = mockResource(); Shadows.shadowOf(Looper.getMainLooper()).pause(); - recycler.recycle(resource, /*forceNextFrame=*/ true); + recycler.recycle(resource, /* forceNextFrame= */ true); verify(resource, never()).recycle(); Shadows.shadowOf(Looper.getMainLooper()).runToEndOfTasks(); verify(resource).recycle(); @@ -52,7 +53,7 @@ public void testDoesNotRecycleChildResourceSynchronously() { new Answer() { @Override public Void answer(InvocationOnMock invocationOnMock) throws Throwable { - recycler.recycle(child, /*forceNextFrame=*/ false); + recycler.recycle(child, /* forceNextFrame= */ false); return null; } }) @@ -61,7 +62,7 @@ public Void answer(InvocationOnMock invocationOnMock) throws Throwable { Shadows.shadowOf(Looper.getMainLooper()).pause(); - recycler.recycle(parent, /*forceNextFrame=*/ false); + recycler.recycle(parent, /* forceNextFrame= */ false); verify(parent).recycle(); verify(child, never()).recycle(); diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/AttributeStrategyKeyTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/AttributeStrategyKeyTest.java index d4f1a824d5..e537a7cf3c 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/AttributeStrategyKeyTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/AttributeStrategyKeyTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.engine.bitmap_recycle; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.mockito.ArgumentMatchers.eq; @@ -16,7 +17,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class AttributeStrategyKeyTest { private AttributeStrategy.KeyPool keyPool; diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/AttributeStrategyTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/AttributeStrategyTest.java index d2337f51e3..47fbb9bb7b 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/AttributeStrategyTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/AttributeStrategyTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.engine.bitmap_recycle; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -11,7 +12,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class AttributeStrategyTest { private AttributeStrategy strategy; diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/GroupedLinkedMapTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/GroupedLinkedMapTest.java index 6689d6d577..8438edf0a3 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/GroupedLinkedMapTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/GroupedLinkedMapTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.engine.bitmap_recycle; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertNull; @@ -10,7 +11,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class GroupedLinkedMapTest { private GroupedLinkedMap map; @@ -22,7 +23,7 @@ public void setUp() { @Test public void testReturnsNullForGetWithNoBitmap() { - Key key = new Key("key", /*width=*/ 1, /*height=*/ 1); + Key key = new Key("key", /* width= */ 1, /* height= */ 1); assertNull(map.get(key)); } diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/LruArrayPoolTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/LruArrayPoolTest.java index 6df26cc5e6..70cd10fe5a 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/LruArrayPoolTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/LruArrayPoolTest.java @@ -4,6 +4,7 @@ import static android.content.ComponentCallbacks2.TRIM_MEMORY_COMPLETE; import static android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL; import static android.content.ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -19,7 +20,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class LruArrayPoolTest { private static final int MAX_SIZE = 10; private static final int MAX_PUT_SIZE = MAX_SIZE / 2; diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/LruBitmapPoolTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/LruBitmapPoolTest.java index 35af88e7dd..6956086069 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/LruBitmapPoolTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/LruBitmapPoolTest.java @@ -14,7 +14,6 @@ import static org.mockito.Mockito.when; import android.graphics.Bitmap; -import android.os.Build; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; @@ -24,7 +23,6 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; -import org.robolectric.Shadows; import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) @@ -52,8 +50,9 @@ public void testICanAddAndGetABitmap() { @Test public void testImmutableBitmapsAreNotAdded() { Bitmap bitmap = createMutableBitmap(); - Shadows.shadowOf(bitmap).setMutable(false); - pool.put(bitmap); + Bitmap immutable = bitmap.copy(Bitmap.Config.ARGB_8888, /* isMutable= */ false); + assertThat(immutable.isMutable()).isFalse(); + pool.put(immutable); assertThat(strategy.bitmaps).isEmpty(); } @@ -99,15 +98,8 @@ public void testEvictedBitmapsAreRecycled() { } } - @Config(sdk = Build.VERSION_CODES.KITKAT) - @Test - public void testTrimMemoryUiHiddenOrLessRemovesHalfOfBitmaps_preM() { - testTrimMemory(MAX_SIZE, TRIM_MEMORY_UI_HIDDEN, MAX_SIZE / 2); - } - - @Config(sdk = Build.VERSION_CODES.M) @Test - public void testTrimMemoryUiHiddenOrLessRemovesHalfOfBitmaps_postM() { + public void testTrimMemoryUiHiddenOrLessRemovesHalfOfBitmaps() { testTrimMemory(MAX_SIZE, TRIM_MEMORY_UI_HIDDEN, 0); } @@ -154,13 +146,13 @@ public void testPassesArgb8888ToStrategyAsConfigForRequestsWithNullConfigsOnGetD @Test public void get_withNullConfig_andEmptyPool_returnsNewArgb8888Bitmap() { - Bitmap result = pool.get(100, 100, /*config=*/ null); + Bitmap result = pool.get(100, 100, /* config= */ null); assertThat(result.getConfig()).isEqualTo(Bitmap.Config.ARGB_8888); } @Test public void getDirty_withNullConfig_andEmptyPool_returnsNewArgb8888Bitmap() { - Bitmap result = pool.getDirty(100, 100, /*config=*/ null); + Bitmap result = pool.getDirty(100, 100, /* config= */ null); assertThat(result.getConfig()).isEqualTo(Bitmap.Config.ARGB_8888); } @@ -225,7 +217,7 @@ public void testBitmapsWithDisallowedConfigsAreIgnored() { } @Test - @Config(sdk = 19) + @Config(sdk = Config.OLDEST_SDK) public void testBitmapsWithAllowedNullConfigsAreAllowed() { pool = new LruBitmapPool(100, strategy, Collections.singleton(null)); @@ -249,7 +241,7 @@ private Bitmap createMutableBitmap() { private Bitmap createMutableBitmap(Bitmap.Config config) { Bitmap bitmap = Bitmap.createBitmap(100, 100, config); - Shadows.shadowOf(bitmap).setMutable(true); + assertThat(bitmap.isMutable()).isTrue(); return bitmap; } diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/SizeConfigStrategyTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/SizeConfigStrategyTest.java index ddfd6d4ab6..6c9321bd98 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/SizeConfigStrategyTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/bitmap_recycle/SizeConfigStrategyTest.java @@ -1,15 +1,15 @@ package com.bumptech.glide.load.engine.bitmap_recycle; import android.graphics.Bitmap; +import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.testing.EqualsTester; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -@RunWith(JUnit4.class) +@RunWith(AndroidJUnit4.class) public class SizeConfigStrategyTest { @Mock private SizeConfigStrategy.KeyPool pool; diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/cache/DiskLruCacheWrapperTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/cache/DiskLruCacheWrapperTest.java index 0dc500cef6..269fecc8c1 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/cache/DiskLruCacheWrapperTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/cache/DiskLruCacheWrapperTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.engine.cache; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; @@ -21,7 +22,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class DiskLruCacheWrapperTest { private DiskCache cache; private byte[] data; diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/cache/MemorySizeCalculatorTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/cache/MemorySizeCalculatorTest.java index 67c4f20e2a..a922e3239a 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/cache/MemorySizeCalculatorTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/cache/MemorySizeCalculatorTest.java @@ -24,7 +24,7 @@ import org.robolectric.shadows.ShadowActivityManager; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 19, shadows = LowRamActivityManager.class) +@Config(sdk = Config.OLDEST_SDK, shadows = LowRamActivityManager.class) public class MemorySizeCalculatorTest { private MemorySizeHarness harness; private int initialSdkVersion; diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/cache/SafeKeyGeneratorTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/cache/SafeKeyGeneratorTest.java index 0510e3e62d..29e7ffbdc8 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/cache/SafeKeyGeneratorTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/cache/SafeKeyGeneratorTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.engine.cache; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertTrue; import androidx.annotation.NonNull; @@ -14,7 +15,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class SafeKeyGeneratorTest { private SafeKeyGenerator keyGenerator; private int nextId; diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/executor/GlideExecutorTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/executor/GlideExecutorTest.java index c9d9438ce8..24bf5c0f09 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/executor/GlideExecutorTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/executor/GlideExecutorTest.java @@ -1,24 +1,92 @@ package com.bumptech.glide.load.engine.executor; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import androidx.annotation.NonNull; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.function.Function; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class GlideExecutorTest { + @Test + public void testOnExecuteDecorator_isCalledAndCanDecorateRunnable() throws InterruptedException { + final CountDownLatch decoratorCalled = new CountDownLatch(1); + final CountDownLatch decoratedRunnableExecuted = new CountDownLatch(1); + + GlideExecutor executor = + GlideExecutor.newDiskCacheBuilder() + .experimentalSetOnExecuteDecorator( + new Function() { + @Override + public Runnable apply(Runnable runnable) { + decoratorCalled.countDown(); + return new Runnable() { + @Override + public void run() { + decoratedRunnableExecuted.countDown(); + runnable.run(); + } + }; + } + }) + .build(); + + final CountDownLatch originalRunnableExecuted = new CountDownLatch(1); + executor.execute( + new Runnable() { + @Override + public void run() { + originalRunnableExecuted.countDown(); + } + }); + + assertThat(decoratorCalled.await(100, TimeUnit.MILLISECONDS)).isTrue(); + assertThat(decoratedRunnableExecuted.await(100, TimeUnit.MILLISECONDS)).isTrue(); + assertThat(originalRunnableExecuted.await(100, TimeUnit.MILLISECONDS)).isTrue(); + + executor.shutdown(); + executor.awaitTermination(500, TimeUnit.MILLISECONDS); + } + + @Test + public void testOnExecuteDecorator_notDecorated_decoratorNotCalled() throws InterruptedException { + final CountDownLatch decoratorCalled = new CountDownLatch(1); + final CountDownLatch decoratedRunnableExecuted = new CountDownLatch(1); + + GlideExecutor executor = GlideExecutor.newDiskCacheBuilder().build(); + + final CountDownLatch originalRunnableExecuted = new CountDownLatch(1); + executor.execute( + new Runnable() { + @Override + public void run() { + originalRunnableExecuted.countDown(); + } + }); + + assertThat(decoratorCalled.await(100, TimeUnit.MILLISECONDS)).isFalse(); + assertThat(decoratedRunnableExecuted.await(100, TimeUnit.MILLISECONDS)).isFalse(); + assertThat(originalRunnableExecuted.await(100, TimeUnit.MILLISECONDS)).isTrue(); + + executor.shutdown(); + executor.awaitTermination(500, TimeUnit.MILLISECONDS); + } + @Test public void testLoadsAreExecutedInOrder() throws InterruptedException { final List resultPriorities = Collections.synchronizedList(new ArrayList()); + CountDownLatch latch = new CountDownLatch(1); GlideExecutor executor = GlideExecutor.newDiskCacheExecutor(); for (int i = 5; i > 0; i--) { executor.execute( @@ -27,10 +95,17 @@ public void testLoadsAreExecutedInOrder() throws InterruptedException { new MockRunnable.OnRun() { @Override public void onRun(int priority) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } resultPriorities.add(priority); } })); } + latch.countDown(); executor.shutdown(); executor.awaitTermination(500, TimeUnit.MILLISECONDS); diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/prefill/BitmapPreFillRunnerTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/prefill/BitmapPreFillRunnerTest.java index 2e5a0cb770..b57c607d17 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/prefill/BitmapPreFillRunnerTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/prefill/BitmapPreFillRunnerTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.engine.prefill; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.bumptech.glide.tests.Util.anyResource; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertNotEquals; @@ -43,7 +44,7 @@ import org.robolectric.shadows.ShadowLog; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class BitmapPreFillRunnerTest { @Mock private BitmapPreFillRunner.Clock clock; @Mock private BitmapPool pool; diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/prefill/BitmapPreFillerTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/prefill/BitmapPreFillerTest.java index 7e63d6ecab..c1382a009e 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/prefill/BitmapPreFillerTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/prefill/BitmapPreFillerTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.engine.prefill; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; @@ -30,7 +31,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class BitmapPreFillerTest { private static final int DEFAULT_BITMAP_WIDTH = 100; private static final int DEFAULT_BITMAP_HEIGHT = 50; @@ -184,7 +185,8 @@ public void testAllocationOrderSplitsEvenlyBetweenEqualSizesWithEqualWeights() { .build(); PreFillQueue allocationOrder = bitmapPreFiller.generateAllocationOrder(smallWidth, smallHeight); - int numSmallWidth = 0, numSmallHeight = 0; + int numSmallWidth = 0; + int numSmallHeight = 0; while (!allocationOrder.isEmpty()) { PreFillType current = allocationOrder.remove(); if (smallWidth.equals(current)) { @@ -211,7 +213,8 @@ public void testAllocationOrderSplitsByteSizeEvenlyBetweenUnEqualSizesWithEqualW .build(); PreFillQueue allocationOrder = bitmapPreFiller.generateAllocationOrder(smallWidth, normal); - int numSmallWidth = 0, numNormal = 0; + int numSmallWidth = 0; + int numNormal = 0; while (!allocationOrder.isEmpty()) { PreFillType current = allocationOrder.remove(); if (smallWidth.equals(current)) { @@ -239,7 +242,8 @@ public void testAllocationOrderSplitsByteSizeUnevenlyBetweenEqualSizesWithUnequa .build(); PreFillQueue allocationOrder = bitmapPreFiller.generateAllocationOrder(doubleWeight, normal); - int numDoubleWeight = 0, numNormal = 0; + int numDoubleWeight = 0; + int numNormal = 0; while (!allocationOrder.isEmpty()) { PreFillType current = allocationOrder.remove(); if (doubleWeight.equals(current)) { diff --git a/library/test/src/test/java/com/bumptech/glide/load/engine/prefill/PreFillTypeTest.java b/library/test/src/test/java/com/bumptech/glide/load/engine/prefill/PreFillTypeTest.java index f3e6acb4de..023094a85d 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/engine/prefill/PreFillTypeTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/engine/prefill/PreFillTypeTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.engine.prefill; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import android.graphics.Bitmap; @@ -10,7 +11,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class PreFillTypeTest { @Test(expected = IllegalArgumentException.class) diff --git a/library/test/src/test/java/com/bumptech/glide/load/model/AssetUriLoaderTest.java b/library/test/src/test/java/com/bumptech/glide/load/model/AssetUriLoaderTest.java index 9f57b3c7ba..57e1f5b38a 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/model/AssetUriLoaderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/model/AssetUriLoaderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.model; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -21,7 +22,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class AssetUriLoaderTest { private static final int IMAGE_SIDE = 10; diff --git a/library/test/src/test/java/com/bumptech/glide/load/model/DataUrlLoaderTest.java b/library/test/src/test/java/com/bumptech/glide/load/model/DataUrlLoaderTest.java index 7c2678d43a..8ea708e9b7 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/model/DataUrlLoaderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/model/DataUrlLoaderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.model; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -26,7 +27,7 @@ /** Tests for the {@link DataUrlLoader} class. */ @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class DataUrlLoaderTest { // A valid base64-encoded PNG (a small "Google" logo). diff --git a/library/test/src/test/java/com/bumptech/glide/load/model/GlideUrlTest.java b/library/test/src/test/java/com/bumptech/glide/load/model/GlideUrlTest.java index 12892f9c4a..35a45f1185 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/model/GlideUrlTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/model/GlideUrlTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.model; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.mock; @@ -13,7 +14,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class GlideUrlTest { @Test(expected = NullPointerException.class) @@ -117,4 +118,12 @@ public void testEquals() throws MalformedURLException { .addEqualityGroup(new GlideUrl(url, otherHeaders), new GlideUrl(new URL(url), otherHeaders)) .testEquals(); } + + @Test + public void issue_5444() throws MalformedURLException { + String original = "http://[2600:1f13:37c:1400:ba21:7165:5fc7:736e]/"; + GlideUrl glideUrl = new GlideUrl(original); + assertThat(glideUrl.toURL().toString()).isEqualTo(original); + assertThat(glideUrl.toStringUrl()).isEqualTo(original); + } } diff --git a/library/test/src/test/java/com/bumptech/glide/load/model/LazyHeadersTest.java b/library/test/src/test/java/com/bumptech/glide/load/model/LazyHeadersTest.java index db2de337f7..c58dc0d411 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/model/LazyHeadersTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/model/LazyHeadersTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.model; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -16,7 +17,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class LazyHeadersTest { private static final String DEFAULT_USER_AGENT = "default_user_agent"; private static final String DEFAULT_USER_AGENT_PROPERTY = "http.agent"; diff --git a/library/test/src/test/java/com/bumptech/glide/load/model/ModelCacheTest.java b/library/test/src/test/java/com/bumptech/glide/load/model/ModelCacheTest.java index 744eba369a..5654a32c8b 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/model/ModelCacheTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/model/ModelCacheTest.java @@ -42,9 +42,11 @@ public void testCanSetAndGetModel() { @Test public void testCanSetAndGetMultipleResultsWithDifferentDimensionsForSameObject() { Object model = new Object(); - int firstWidth = 10, firstHeight = 20; + int firstWidth = 10; + int firstHeight = 20; Object firstResult = new Object(); - int secondWidth = 30, secondHeight = 40; + int secondWidth = 30; + int secondHeight = 40; Object secondResult = new Object(); cache.put(model, firstWidth, firstHeight, firstResult); diff --git a/library/test/src/test/java/com/bumptech/glide/load/model/MultiModelLoaderFactoryTest.java b/library/test/src/test/java/com/bumptech/glide/load/model/MultiModelLoaderFactoryTest.java index e2fa7f4c2f..605cab562e 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/model/MultiModelLoaderFactoryTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/model/MultiModelLoaderFactoryTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.model; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.eq; @@ -26,7 +27,7 @@ // containsExactly produces a spurious warning. @SuppressWarnings("ResultOfMethodCallIgnored") @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class MultiModelLoaderFactoryTest { @Mock private ModelLoaderFactory firstFactory; @Mock private ModelLoader firstModelLoader; diff --git a/library/test/src/test/java/com/bumptech/glide/load/model/ResourceLoaderTest.java b/library/test/src/test/java/com/bumptech/glide/load/model/ResourceLoaderTest.java index e66f86bc6f..708dba9d2f 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/model/ResourceLoaderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/model/ResourceLoaderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.model; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -26,7 +27,7 @@ /** Tests for the {@link com.bumptech.glide.load.model.ResourceLoader} class. */ @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class ResourceLoaderTest { @Mock private ModelLoader uriLoader; @@ -48,7 +49,7 @@ public void setUp() { @Test public void testCanHandleId() { int id = android.R.drawable.star_off; - Uri contentUri = Uri.parse("android.resource://android/drawable/star_off"); + Uri contentUri = Uri.parse("android.resource://android/" + String.valueOf(id)); when(uriLoader.buildLoadData(eq(contentUri), anyInt(), anyInt(), any(Options.class))) .thenReturn(new ModelLoader.LoadData<>(key, fetcher)); diff --git a/library/test/src/test/java/com/bumptech/glide/load/model/StreamEncoderTest.java b/library/test/src/test/java/com/bumptech/glide/load/model/StreamEncoderTest.java index 8185adaece..b34c4bcb27 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/model/StreamEncoderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/model/StreamEncoderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.model; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import androidx.test.core.app.ApplicationProvider; @@ -17,7 +18,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class StreamEncoderTest { private StreamEncoder encoder; private File file; diff --git a/library/test/src/test/java/com/bumptech/glide/load/model/StringLoaderTest.java b/library/test/src/test/java/com/bumptech/glide/load/model/StringLoaderTest.java index 9122f98f66..831500a924 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/model/StringLoaderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/model/StringLoaderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.model; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -26,7 +27,7 @@ /** Tests for the {@link com.bumptech.glide.load.model.StringLoader} class. */ @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class StringLoaderTest { // Not a magic number, just an arbitrary non zero value. private static final int IMAGE_SIDE = 100; diff --git a/library/test/src/test/java/com/bumptech/glide/load/model/UriLoaderTest.java b/library/test/src/test/java/com/bumptech/glide/load/model/UriLoaderTest.java index 0568ef091a..daa1e51cc9 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/model/UriLoaderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/model/UriLoaderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.model; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.eq; @@ -21,7 +22,7 @@ /** Tests for the {@link UriLoader} class. */ @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class UriLoaderTest { // Not a magic number, just arbitrary non zero. private static final int IMAGE_SIDE = 120; @@ -51,19 +52,6 @@ public void testHandlesFileUris() throws IOException { .fetcher); } - @Test - public void testHandlesResourceUris() throws IOException { - Uri resourceUri = Uri.parse("android.resource://com.bumptech.glide.tests/raw/ic_launcher"); - when(factory.build(eq(resourceUri))).thenReturn(localUriFetcher); - - assertTrue(loader.handles(resourceUri)); - assertEquals( - localUriFetcher, - Preconditions.checkNotNull( - loader.buildLoadData(resourceUri, IMAGE_SIDE, IMAGE_SIDE, options)) - .fetcher); - } - @Test public void testHandlesContentUris() { Uri contentUri = Uri.parse("content://com.bumptech.glide"); diff --git a/library/test/src/test/java/com/bumptech/glide/load/model/UrlUriLoaderTest.java b/library/test/src/test/java/com/bumptech/glide/load/model/UrlUriLoaderTest.java index 1c2db6f1f8..f8af156398 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/model/UrlUriLoaderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/model/UrlUriLoaderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.model; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; @@ -17,7 +18,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class UrlUriLoaderTest { private static final int IMAGE_SIDE = 100; private static final Options OPTIONS = new Options(); diff --git a/library/test/src/test/java/com/bumptech/glide/load/model/stream/BaseGlideUrlLoaderTest.java b/library/test/src/test/java/com/bumptech/glide/load/model/stream/BaseGlideUrlLoaderTest.java index b51bcfaf66..7ff18aa9cc 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/model/stream/BaseGlideUrlLoaderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/model/stream/BaseGlideUrlLoaderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.model.stream; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.mockito.ArgumentMatchers.any; @@ -29,7 +30,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class BaseGlideUrlLoaderTest { @Mock private ModelCache modelCache; diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/UnitTransformationTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/UnitTransformationTest.java index b6030a92cc..3fc1e22574 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/UnitTransformationTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/UnitTransformationTest.java @@ -7,6 +7,8 @@ import static org.mockito.Mockito.mock; import android.app.Application; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; import com.bumptech.glide.load.Transformation; import com.bumptech.glide.load.engine.Resource; import com.bumptech.glide.tests.KeyTester; @@ -17,10 +19,8 @@ import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; -import org.robolectric.RuntimeEnvironment; -@RunWith(JUnit4.class) +@RunWith(AndroidJUnit4.class) public class UnitTransformationTest { @Rule public final KeyTester keyTester = new KeyTester(); @@ -28,7 +28,7 @@ public class UnitTransformationTest { @Before public void setUp() { - app = RuntimeEnvironment.application; + app = ApplicationProvider.getApplicationContext(); } @Test diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapDrawableResourceTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapDrawableResourceTest.java index 3da1166694..ff7597035d 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapDrawableResourceTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapDrawableResourceTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.bitmap; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotSame; import static org.mockito.ArgumentMatchers.eq; @@ -17,7 +18,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class BitmapDrawableResourceTest { private BitmapDrawableResourceHarness harness; diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapDrawableTransformationTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapDrawableTransformationTest.java index c2397235ab..58cc355771 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapDrawableTransformationTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapDrawableTransformationTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.bitmap; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.bumptech.glide.tests.Util.anyContext; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -37,7 +38,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) @SuppressWarnings("deprecation") public class BitmapDrawableTransformationTest { @Rule public final KeyTester keyTester = new KeyTester(); diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapEncoderTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapEncoderTest.java index 7600d9e5ea..cead09fb7b 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapEncoderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapEncoderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.bitmap; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.bumptech.glide.tests.Util.mockResource; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; @@ -26,7 +27,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class BitmapEncoderTest { private EncoderHarness harness; @@ -42,6 +43,8 @@ public void tearDown() { @Test public void testBitmapIsEncoded() throws IOException { + harness.bitmap.setHasAlpha(false); + assertThat(harness.encode()).isEqualTo(harness.expectedData(CompressFormat.JPEG, 90)); } @@ -49,6 +52,7 @@ public void testBitmapIsEncoded() throws IOException { public void testBitmapIsEncodedWithGivenQuality() throws IOException { int quality = 7; harness.setQuality(quality); + harness.bitmap.setHasAlpha(false); assertThat(harness.encode()).isEqualTo(harness.expectedData(CompressFormat.JPEG, quality)); } diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapResourceTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapResourceTest.java index 9ce34dd7a8..da38169052 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapResourceTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapResourceTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.bitmap; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -18,7 +19,7 @@ // TODO: add a test for bitmap size using getAllocationByteSize when robolectric supports kitkat. @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class BitmapResourceTest { private int currentBuildVersion; private BitmapResourceHarness harness; diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapTransformationTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapTransformationTest.java index 7ea1d86a63..fd2cc43966 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapTransformationTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/BitmapTransformationTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.bitmap; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; @@ -26,7 +27,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class BitmapTransformationTest { @Mock private BitmapPool bitmapPool; diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/CenterCropTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/CenterCropTest.java index a3fcef89f5..2878b2124f 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/CenterCropTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/CenterCropTest.java @@ -100,7 +100,7 @@ public void testDoesNotRecycleGivenResource() { } @Test - @Config(sdk = 19) + @Config(sdk = Config.OLDEST_SDK) public void testAsksBitmapPoolForArgb8888IfInConfigIsNull() { bitmap.setConfig(null); diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/CenterInsideTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/CenterInsideTest.java index bcdc8c454e..9dfe1e2f11 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/CenterInsideTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/CenterInsideTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.bitmap; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.mockito.ArgumentMatchers.any; @@ -10,9 +11,6 @@ import android.app.Application; import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Matrix; -import android.graphics.Paint; import androidx.test.core.app.ApplicationProvider; import com.bumptech.glide.Glide; import com.bumptech.glide.GlideBuilder; @@ -33,14 +31,9 @@ import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; -import org.robolectric.annotation.Implementation; -import org.robolectric.annotation.Implements; -import org.robolectric.shadows.ShadowCanvas; @RunWith(RobolectricTestRunner.class) -@Config( - sdk = 18, - shadows = {CenterInsideTest.DrawNothingCanvas.class}) +@Config(sdk = ROBOLECTRIC_SDK) public class CenterInsideTest { @Rule public final KeyTester keyTester = new KeyTester(); @@ -119,14 +112,4 @@ public void testEquals() throws NoSuchAlgorithmException { new CenterInside(), "acf83850a2e8e9e809c8bfb999e2aede9e932cb897a15367fac9856b96f3ba33") .test(); } - - @Implements(Canvas.class) - public static final class DrawNothingCanvas extends ShadowCanvas { - - @Implementation - @Override - public void drawBitmap(Bitmap bitmap, Matrix matrix, Paint paint) { - // Do nothing. - } - } } diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/DefaultImageHeaderParserTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/DefaultImageHeaderParserTest.java index 80fadfe0ab..4394de7f8f 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/DefaultImageHeaderParserTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/DefaultImageHeaderParserTest.java @@ -2,6 +2,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import androidx.annotation.NonNull; import com.bumptech.glide.load.ImageHeaderParser; @@ -9,6 +10,7 @@ import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; import com.bumptech.glide.load.engine.bitmap_recycle.LruArrayPool; import com.bumptech.glide.testutil.TestResourceUtil; +import com.google.common.io.ByteStreams; import java.io.ByteArrayInputStream; import java.io.FilterInputStream; import java.io.IOException; @@ -18,11 +20,8 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; -import org.robolectric.util.Util; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) public class DefaultImageHeaderParserTest { private static final byte[] PNG_HEADER_WITH_IHDR_CHUNK = @@ -175,7 +174,7 @@ public void run( } @Test - public void testCanParseWebpWithAlpha() throws IOException { + public void testCanParseLosslessWebpWithAlpha() throws IOException { byte[] data = new byte[] { 0x52, @@ -193,12 +192,12 @@ public void testCanParseWebpWithAlpha() throws IOException { 0x56, 0x50, 0x38, - 0x4c, + 0x4c, // Lossless 0x30, 0x50, 0x00, 0x00, - 0x2f, + 0x2f, // Flags (byte) 0xef, (byte) 0x80, 0x15, @@ -230,41 +229,657 @@ public void run( } @Test - public void testCanParseWebpWithoutAlpha() throws IOException { + public void testCanParseLosslessWebpWithoutAlpha() throws IOException { byte[] data = new byte[] { 0x52, 0x49, 0x46, 0x46, - 0x72, - 0x1c, + 0x3c, + 0x50, + 0x00, + 0x00, + 0x57, + 0x45, + 0x42, + 0x50, + 0x56, + 0x50, + 0x38, + 0x4c, // Lossless + 0x30, + 0x50, + 0x00, + 0x00, + 0x00, // Flags + (byte) 0xef, + (byte) 0x80, + 0x15, + 0x10, + (byte) 0x8d, + 0x30, + 0x68, + 0x1b, + (byte) 0xc9, + (byte) 0x91, + (byte) 0xb2 + }; + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.WEBP, parser.getType(is)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.WEBP, parser.getType(byteBuffer)); + } + }); + } + + @Test + public void testCanParseExtendedWebpWithAlpha() throws IOException { + byte[] data = + new byte[] { + 0x52, + 0x49, + 0x46, + 0x46, + 0x3c, + 0x50, + 0x00, + 0x00, + 0x57, + 0x45, + 0x42, + 0x50, + 0x56, + 0x50, + 0x38, + 0x58, // Extended + 0x30, + 0x50, + 0x00, + 0x00, + 0x10, // flags + (byte) 0xef, + (byte) 0x80, + 0x15, + 0x10, + (byte) 0x8d, + 0x30, + 0x68, + 0x1b, + (byte) 0xc9, + (byte) 0x91, + (byte) 0xb2 + }; + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.WEBP_A, parser.getType(is)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.WEBP_A, parser.getType(byteBuffer)); + } + }); + } + + @Test + public void testCanParseExtendedWebpWithoutAlpha() throws IOException { + byte[] data = + new byte[] { + 0x52, + 0x49, + 0x46, + 0x46, + 0x3c, + 0x50, + 0x00, + 0x00, + 0x57, + 0x45, + 0x42, + 0x50, + 0x56, + 0x50, + 0x38, + 0x58, // Extended + 0x30, + 0x50, + 0x00, + 0x00, + 0x00, // flags + (byte) 0xef, + (byte) 0x80, + 0x15, + 0x10, + (byte) 0x8d, + 0x30, + 0x68, + 0x1b, + (byte) 0xc9, + (byte) 0x91, + (byte) 0xb2 + }; + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.WEBP, parser.getType(is)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.WEBP, parser.getType(byteBuffer)); + } + }); + } + + @Test + public void testCanParseExtendedWebpWithoutAlphaAndWithAnimation() throws IOException { + byte[] data = + new byte[] { + 0x52, + 0x49, + 0x46, + 0x46, + 0x3c, + 0x50, + 0x00, + 0x00, + 0x57, + 0x45, + 0x42, + 0x50, + 0x56, + 0x50, + 0x38, + 0x58, // Extended + 0x30, + 0x50, + 0x00, + 0x00, + 0x02, // Flags + (byte) 0xef, + (byte) 0x80, + 0x15, + 0x10, + (byte) 0x8d, + 0x30, + 0x68, + 0x1b, + (byte) 0xc9, + (byte) 0x91, + (byte) 0xb2 + }; + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.ANIMATED_WEBP, parser.getType(is)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.ANIMATED_WEBP, parser.getType(byteBuffer)); + } + }); + } + + @Test + public void testCanParseExtendedWebpWithAlphaAndAnimation() throws IOException { + byte[] data = + new byte[] { + 0x52, + 0x49, + 0x46, + 0x46, + 0x3c, + 0x50, + 0x00, + 0x00, + 0x57, + 0x45, + 0x42, + 0x50, + 0x56, + 0x50, + 0x38, + 0x58, // Extended + 0x30, + 0x50, + 0x00, + 0x00, + (byte) 0x12, // Flags + (byte) 0xef, + (byte) 0x80, + 0x15, + 0x10, + (byte) 0x8d, + 0x30, + 0x68, + 0x1b, + (byte) 0xc9, + (byte) 0x91, + (byte) 0xb2 + }; + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.ANIMATED_WEBP, parser.getType(is)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.ANIMATED_WEBP, parser.getType(byteBuffer)); + } + }); + } + + @Test + public void testCanParseRealAnimatedWebpFile() throws IOException { + byte[] data = + ByteStreams.toByteArray(TestResourceUtil.openResource(getClass(), "animated_webp.webp")); + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertThat(parser.getType(is)).isEqualTo(ImageType.ANIMATED_WEBP); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertThat(parser.getType(byteBuffer)).isEqualTo(ImageType.ANIMATED_WEBP); + } + }); + } + + @Test + public void testCanParseAvifMajorBrand() throws IOException { + byte[] data = + new byte[] { + // Box Size. + 0x00, + 0x00, + 0x00, + 0x1C, + // ftyp. + 0x66, + 0x74, + 0x79, + 0x70, + // avif (major brand). + 0x61, + 0x76, + 0x69, + 0x66, + // minor version. + 0x00, + 0x00, + 0x00, + 0x00, + // other minor brands (mif1, miaf, MA1B). + 0x6d, + 0x69, + 0x66, + 0x31, + 0x6d, + 0x69, + 0x61, + 0x66, + 0x4d, + 0x41, + 0x31, + 0x42 + }; + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.AVIF, parser.getType(is)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.AVIF, parser.getType(byteBuffer)); + } + }); + // Change the major brand from 'avif' to 'avis'. Now, the expected output is ANIMATED_AVIF. + data[11] = 0x73; + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.ANIMATED_AVIF, parser.getType(is)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.ANIMATED_AVIF, parser.getType(byteBuffer)); + } + }); + } + + @Test + public void testCanParseAvifMinorBrand() throws IOException { + byte[] data = + new byte[] { + // Box Size. 0x00, 0x00, - 0x57, - 0x45, + 0x00, + 0x1C, + // ftyp. + 0x66, + 0x74, + 0x79, + 0x70, + // mif1 (major brand). + 0x6d, + 0x69, + 0x66, + 0x31, + // minor version. + 0x00, + 0x00, + 0x00, + 0x00, + // other minor brands (miaf, avif, MA1B). + 0x6d, + 0x69, + 0x61, + 0x66, + 0x61, + 0x76, + 0x69, + 0x66, + 0x4d, + 0x41, + 0x31, + 0x42 + }; + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.AVIF, parser.getType(is)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.AVIF, parser.getType(byteBuffer)); + } + }); + // Change the last minor brand from 'MA1B' to 'avis'. Now, the expected output is ANIMATED_AVIF. + data[24] = 0x61; + data[25] = 0x76; + data[26] = 0x69; + data[27] = 0x73; + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.ANIMATED_AVIF, parser.getType(is)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.ANIMATED_AVIF, parser.getType(byteBuffer)); + } + }); + } + + @Test + public void testCanParseAvifAndAvisBrandsAsAnimatedAvif() throws IOException { + byte[] data = + new byte[] { + // Box Size. + 0x00, + 0x00, + 0x00, + 0x1C, + // ftyp. + 0x66, + 0x74, + 0x79, + 0x70, + // avis (major brand). + 0x61, + 0x76, + 0x69, + 0x73, + // minor version. + 0x00, + 0x00, + 0x00, + 0x00, + // other minor brands (miaf, avif, MA1B). + 0x6d, + 0x69, + 0x61, + 0x66, + 0x61, + 0x76, + 0x69, + 0x66, + 0x4d, + 0x41, + 0x31, + 0x42 + }; + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.ANIMATED_AVIF, parser.getType(is)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.ANIMATED_AVIF, parser.getType(byteBuffer)); + } + }); + // Change the major brand from 'avis' to 'avif'. + data[11] = 0x66; + // Change the minor brand from 'avif' to 'avis'. + data[23] = 0x73; + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.ANIMATED_AVIF, parser.getType(is)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.ANIMATED_AVIF, parser.getType(byteBuffer)); + } + }); + } + + @Test + public void testCannotParseAvifMoreThanFiveMinorBrands() throws IOException { + byte[] data = + new byte[] { + // Box Size. + 0x00, + 0x00, + 0x00, + 0x28, + // ftyp. + 0x66, + 0x74, + 0x79, + 0x70, + // mif1 (major brand). + 0x6d, + 0x69, + 0x66, + 0x31, + // minor version. + 0x00, + 0x00, + 0x00, + 0x00, + // more than five minor brands with the sixth one being avif (mif1, miaf, MA1B, mif1, + // miab, avif). + 0x6d, + 0x69, + 0x66, + 0x31, + 0x6d, + 0x69, + 0x61, + 0x66, + 0x4d, + 0x41, + 0x31, 0x42, - 0x50, - 0x56, - 0x50, - 0x38, - 0x20, + 0x6d, + 0x69, + 0x66, + 0x31, + 0x6d, + 0x69, + 0x61, + 0x66, + 0x61, + 0x76, + 0x69, + 0x66, + }; + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertNotEquals(ImageType.AVIF, parser.getType(is)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertNotEquals(ImageType.AVIF, parser.getType(byteBuffer)); + } + }); + } + + @Test + public void testCanParseRealAnimatedAvifFile() throws IOException { + byte[] data = + ByteStreams.toByteArray(TestResourceUtil.openResource(getClass(), "animated_avif.avif")); + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertThat(parser.getType(is)).isEqualTo(ImageType.ANIMATED_AVIF); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertThat(parser.getType(byteBuffer)).isEqualTo(ImageType.ANIMATED_AVIF); + } + }); + } + + @Test + public void testCanParseHeifMajorBrand() throws IOException { + byte[] data = + new byte[] { + // Box Size. + 0x00, + 0x00, + 0x00, + 0x1C, + // ftyp. 0x66, - 0x1c, + 0x74, + 0x79, + 0x70, + // heic (major brand). + 0x68, + 0x65, + 0x69, + 0x63, + // minor version. 0x00, 0x00, - 0x30, - 0x3c, - 0x01, - (byte) 0x9d, - 0x01, - 0x2a, - 0x52, - 0x02, - (byte) 0x94, - 0x03, 0x00, - (byte) 0xc7 + 0x00, + // other minor brands (mif1, msf1, hevc). + 0x6d, + 0x69, + 0x66, + 0x31, + 0x6d, + 0x73, + 0x66, + 0x31, + 0x68, + 0x65, + 0x76, + 0x63 }; runTest( data, @@ -272,14 +887,70 @@ public void testCanParseWebpWithoutAlpha() throws IOException { @Override public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) throws IOException { - assertEquals(ImageType.WEBP, parser.getType(is)); + assertEquals(ImageType.HEIF, parser.getType(is)); } @Override public void run( DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) throws IOException { - assertEquals(ImageType.WEBP, parser.getType(byteBuffer)); + assertEquals(ImageType.HEIF, parser.getType(byteBuffer)); + } + }); + } + + @Test + public void testCanParseHeifMinorBrand() throws IOException { + byte[] data = + new byte[] { + // Box Size. + 0x00, + 0x00, + 0x00, + 0x1C, + // ftyp. + 0x66, + 0x74, + 0x79, + 0x70, + // mif1 (major brand). + 0x6d, + 0x69, + 0x66, + 0x31, + // minor version. + 0x00, + 0x00, + 0x00, + 0x00, + // other minor brands (msf1, heic, hevc). + 0x6d, + 0x73, + 0x66, + 0x31, + 0x68, + 0x65, + 0x69, + 0x63, + 0x68, + 0x65, + 0x76, + 0x63 + }; + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.HEIF, parser.getType(is)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertEquals(ImageType.HEIF, parser.getType(byteBuffer)); } }); } @@ -309,7 +980,7 @@ public void run( @Test public void testHandlesParsingOrientationWithMinimalExifSegment() throws IOException { byte[] data = - Util.readBytes(TestResourceUtil.openResource(getClass(), "short_exif_sample.jpg")); + ByteStreams.toByteArray(TestResourceUtil.openResource(getClass(), "short_exif_sample.jpg")); runTest( data, new ParserTestCase() { @@ -456,6 +1127,51 @@ public void getOrientation_withExifSegmentAndPreambleBetweenLengthAndExpected_re assertEquals(ImageHeaderParser.UNKNOWN_ORIENTATION, parser.getOrientation(data, byteArrayPool)); } + @Test + public void hasJpegMpf_withGainmapFile_returnsTrue() throws IOException { + byte[] data = + ByteStreams.toByteArray( + TestResourceUtil.openResource(getClass(), "small_gainmap_image.jpg")); + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertEquals(true, parser.hasJpegMpf(is, byteArrayPool)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertEquals(true, parser.hasJpegMpf(byteBuffer, byteArrayPool)); + } + }); + } + + @Test + public void hasJpegMpf_withNonGainmapFile_returnsFalse() throws IOException { + byte[] data = + ByteStreams.toByteArray(TestResourceUtil.openResource(getClass(), "short_exif_sample.jpg")); + runTest( + data, + new ParserTestCase() { + @Override + public void run(DefaultImageHeaderParser parser, InputStream is, ArrayPool byteArrayPool) + throws IOException { + assertEquals(false, parser.hasJpegMpf(is, byteArrayPool)); + } + + @Override + public void run( + DefaultImageHeaderParser parser, ByteBuffer byteBuffer, ArrayPool byteArrayPool) + throws IOException { + assertEquals(false, parser.hasJpegMpf(byteBuffer, byteArrayPool)); + } + }); + } + private static ByteBuffer getExifMagicNumber() { ByteBuffer jpegHeaderBytes = ByteBuffer.allocate(2); jpegHeaderBytes.putShort((short) DefaultImageHeaderParser.EXIF_MAGIC_NUMBER); diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/DownsampleStrategyTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/DownsampleStrategyTest.java index 74b4b27895..d9204ee2f9 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/DownsampleStrategyTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/DownsampleStrategyTest.java @@ -8,7 +8,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 21) +@Config(sdk = Config.OLDEST_SDK) public class DownsampleStrategyTest { @Test diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/DrawableTransformationTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/DrawableTransformationTest.java index c83f104787..5aeab4e09b 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/DrawableTransformationTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/DrawableTransformationTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.bitmap; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -39,7 +40,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class DrawableTransformationTest { @Rule public final KeyTester keyTester = new KeyTester(); @Mock private Transformation bitmapTransformation; @@ -50,7 +51,7 @@ public class DrawableTransformationTest { @Before public void setUp() { MockitoAnnotations.initMocks(this); - transformation = new DrawableTransformation(bitmapTransformation, /*isRequired=*/ true); + transformation = new DrawableTransformation(bitmapTransformation, /* isRequired= */ true); context = ApplicationProvider.getApplicationContext(); bitmapPool = new BitmapPoolAdapter(); Glide.init(context, new GlideBuilder().setBitmapPool(bitmapPool)); @@ -72,7 +73,7 @@ public void transform_withBitmapDrawable_andUnitBitmapTransformation_doesNotRecy @SuppressWarnings("unchecked") Resource input = (Resource) (Resource) new BitmapDrawableResource(drawable, bitmapPool); - transformation.transform(context, input, /*outWidth=*/ 100, /*outHeight=*/ 200); + transformation.transform(context, input, /* outWidth= */ 100, /* outHeight= */ 200); assertThat(bitmap.isRecycled()).isFalse(); } @@ -94,7 +95,7 @@ public Resource answer(InvocationOnMock invocationOnMock) throws Throwab @SuppressWarnings("unchecked") Resource input = (Resource) (Resource) new BitmapDrawableResource(drawable, bitmapPool); - transformation.transform(context, input, /*outWidth=*/ 100, /*outHeight=*/ 200); + transformation.transform(context, input, /* outWidth= */ 100, /* outHeight= */ 200); assertThat(bitmap.isRecycled()).isFalse(); } @@ -135,7 +136,7 @@ public Bitmap answer(InvocationOnMock invocationOnMock) throws Throwable { } }); - transformation.transform(context, input, /*outWidth=*/ 100, /*outHeight=*/ 200); + transformation.transform(context, input, /* outWidth= */ 100, /* outHeight= */ 200); verify(bitmapPool).put(isA(Bitmap.class)); } @@ -153,18 +154,18 @@ public void testEquals() { keyTester .addEquivalenceGroup( transformation, - new DrawableTransformation(bitmapTransformation, /*isRequired=*/ true), - new DrawableTransformation(bitmapTransformation, /*isRequired=*/ false)) + new DrawableTransformation(bitmapTransformation, /* isRequired= */ true), + new DrawableTransformation(bitmapTransformation, /* isRequired= */ false)) .addEquivalenceGroup(bitmapTransformation) .addEquivalenceGroup(otherBitmapTransformation) .addEquivalenceGroup( - new DrawableTransformation(otherBitmapTransformation, /*isRequired=*/ true), - new DrawableTransformation(otherBitmapTransformation, /*isRequired=*/ false)) + new DrawableTransformation(otherBitmapTransformation, /* isRequired= */ true), + new DrawableTransformation(otherBitmapTransformation, /* isRequired= */ false)) .addRegressionTest( - new DrawableTransformation(bitmapTransformation, /*isRequired=*/ true), + new DrawableTransformation(bitmapTransformation, /* isRequired= */ true), "eddf60c557a6315a489b8a3a19b12439a90381256289fbe9a503afa726230bd9") .addRegressionTest( - new DrawableTransformation(otherBitmapTransformation, /*isRequired=*/ false), + new DrawableTransformation(otherBitmapTransformation, /* isRequired= */ false), "40931536ed0ec97c39d4be10c44f5b69a86030ec575317f5a0f17e15a0ea9be8") .test(); } diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/FitCenterTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/FitCenterTest.java index 81a9f5691f..f49de5ae6b 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/FitCenterTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/FitCenterTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.bitmap; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; @@ -9,9 +10,6 @@ import android.app.Application; import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Matrix; -import android.graphics.Paint; import androidx.test.core.app.ApplicationProvider; import com.bumptech.glide.Glide; import com.bumptech.glide.GlideBuilder; @@ -32,14 +30,9 @@ import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; -import org.robolectric.annotation.Implementation; -import org.robolectric.annotation.Implements; -import org.robolectric.shadows.ShadowCanvas; @RunWith(RobolectricTestRunner.class) -@Config( - sdk = 18, - shadows = {FitCenterTest.DrawNothingCanvas.class}) +@Config(sdk = ROBOLECTRIC_SDK) public class FitCenterTest { @Rule public final KeyTester keyTester = new KeyTester(); @@ -103,14 +96,4 @@ public void testEquals() throws NoSuchAlgorithmException { new FitCenter(), "eda03bc6969032145110add4bfe399915897406f4ca3a1a7512d07750e60f90d") .test(); } - - @Implements(Canvas.class) - public static final class DrawNothingCanvas extends ShadowCanvas { - - @Implementation - @Override - public void drawBitmap(Bitmap bitmap, Matrix matrix, Paint paint) { - // Do nothing. - } - } } diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/HardwareConfigStateTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/HardwareConfigStateTest.java index d1f9f2a8ff..0e5a23e469 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/HardwareConfigStateTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/HardwareConfigStateTest.java @@ -15,8 +15,9 @@ @RunWith(RobolectricTestRunner.class) @Config(manifest = Config.NONE) public class HardwareConfigStateTest { + private static final int VALID_DIMENSION = 100; - @Config(sdk = Build.VERSION_CODES.O) + @Config(sdk = Build.VERSION_CODES.P) @Test public void setHardwareConfigIfAllowed_withAllowedState_setsInPreferredConfigAndMutable_returnsTrue() { @@ -25,17 +26,17 @@ public class HardwareConfigStateTest { BitmapFactory.Options options = new BitmapFactory.Options(); boolean result = state.setHardwareConfigIfAllowed( - /*targetWidth=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, - /*targetHeight=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, + /* targetWidth= */ VALID_DIMENSION, + /* targetHeight= */ VALID_DIMENSION, options, - /*isHardwareConfigAllowed=*/ true, - /*isExifOrientationRequired=*/ false); + /* isHardwareConfigAllowed= */ true, + /* isExifOrientationRequired= */ false); assertThat(result).isTrue(); assertThat(options.inPreferredConfig).isEqualTo(Bitmap.Config.HARDWARE); } - @Config(sdk = Build.VERSION_CODES.O) + @Config(sdk = Build.VERSION_CODES.P) @Test public void setHardwareConfigIfAllowed_withAllowedState_afterReblock_returnsFalseAndDoesNotSetValues() { @@ -45,19 +46,19 @@ public class HardwareConfigStateTest { BitmapFactory.Options options = new BitmapFactory.Options(); boolean result = state.setHardwareConfigIfAllowed( - /*targetWidth=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, - /*targetHeight=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, + /* targetWidth= */ VALID_DIMENSION, + /* targetHeight= */ VALID_DIMENSION, options, - /*isHardwareConfigAllowed=*/ true, - /*isExifOrientationRequired=*/ false); + /* isHardwareConfigAllowed= */ true, + /* isExifOrientationRequired= */ false); assertThat(result).isFalse(); assertThat(options.inPreferredConfig).isNotEqualTo(Bitmap.Config.HARDWARE); } - @Config(sdk = Build.VERSION_CODES.O) + @Config(sdk = Build.VERSION_CODES.P) @Test - public void setHardwareConfigIfAllowed_withSmallerThanMinWidth_returnsFalse_doesNotSetValues() { + public void setHardwareConfigIfAllowed_withInvalidWidth_returnsFalse_doesNotSetValues() { HardwareConfigState state = new HardwareConfigState(); state.unblockHardwareBitmaps(); BitmapFactory.Options options = new BitmapFactory.Options(); @@ -66,20 +67,20 @@ public void setHardwareConfigIfAllowed_withSmallerThanMinWidth_returnsFalse_does boolean result = state.setHardwareConfigIfAllowed( - /*targetWidth=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O - 1, - /*targetHeight=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, + /* targetWidth= */ -1, + /* targetHeight= */ VALID_DIMENSION, options, - /*isHardwareConfigAllowed=*/ true, - /*isExifOrientationRequired=*/ false); + /* isHardwareConfigAllowed= */ true, + /* isExifOrientationRequired= */ false); assertThat(result).isFalse(); assertThat(options.inMutable).isTrue(); assertThat(options.inPreferredConfig).isNull(); } - @Config(sdk = Build.VERSION_CODES.O) + @Config(sdk = Build.VERSION_CODES.P) @Test - public void setHardwareConfigIfAllowed_withSmallerThanMinHeight_returnsFalse_doesNotSetValues() { + public void setHardwareConfigIfAllowed_withInvalidHeight_returnsFalse_doesNotSetValues() { HardwareConfigState state = new HardwareConfigState(); state.unblockHardwareBitmaps(); BitmapFactory.Options options = new BitmapFactory.Options(); @@ -88,18 +89,18 @@ public void setHardwareConfigIfAllowed_withSmallerThanMinHeight_returnsFalse_doe boolean result = state.setHardwareConfigIfAllowed( - /*targetWidth=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, - /*targetHeight=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O - 1, + /* targetWidth= */ VALID_DIMENSION, + /* targetHeight= */ -1, options, - /*isHardwareConfigAllowed=*/ true, - /*isExifOrientationRequired=*/ false); + /* isHardwareConfigAllowed= */ true, + /* isExifOrientationRequired= */ false); assertThat(result).isFalse(); assertThat(options.inMutable).isTrue(); assertThat(options.inPreferredConfig).isNull(); } - @Config(sdk = Build.VERSION_CODES.O) + @Config(sdk = Build.VERSION_CODES.P) @Test public void setHardwareConfigIfAllowed_withHardwareConfigDisallowed_returnsFalse_doesNotSetValues() { @@ -111,18 +112,18 @@ public void setHardwareConfigIfAllowed_withSmallerThanMinHeight_returnsFalse_doe boolean result = state.setHardwareConfigIfAllowed( - /*targetWidth=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, - /*targetHeight=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, + /* targetWidth= */ VALID_DIMENSION, + /* targetHeight= */ VALID_DIMENSION, options, - /*isHardwareConfigAllowed=*/ false, - /*isExifOrientationRequired=*/ false); + /* isHardwareConfigAllowed= */ false, + /* isExifOrientationRequired= */ false); assertThat(result).isFalse(); assertThat(options.inMutable).isTrue(); assertThat(options.inPreferredConfig).isNull(); } - @Config(sdk = Build.VERSION_CODES.O) + @Config(sdk = Build.VERSION_CODES.P) @Test public void setHardwareConfigIfAllowed_withExifOrientationRequired_returnsFalse_doesNotSetValues() { @@ -134,11 +135,11 @@ public void setHardwareConfigIfAllowed_withSmallerThanMinHeight_returnsFalse_doe boolean result = state.setHardwareConfigIfAllowed( - /*targetWidth=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, - /*targetHeight=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, + /* targetWidth= */ VALID_DIMENSION, + /* targetHeight= */ VALID_DIMENSION, options, - /*isHardwareConfigAllowed=*/ true, - /*isExifOrientationRequired=*/ true); + /* isHardwareConfigAllowed= */ true, + /* isExifOrientationRequired= */ true); assertThat(result).isFalse(); assertThat(options.inMutable).isTrue(); @@ -156,11 +157,11 @@ public void setHardwareConfigIfAllowed_withOsLessThanO_returnsFalse_doesNotSetVa boolean result = state.setHardwareConfigIfAllowed( - /*targetWidth=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, - /*targetHeight=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, + /* targetWidth= */ VALID_DIMENSION, + /* targetHeight= */ VALID_DIMENSION, options, - /*isHardwareConfigAllowed=*/ true, - /*isExifOrientationRequired=*/ false); + /* isHardwareConfigAllowed= */ true, + /* isExifOrientationRequired= */ false); assertThat(result).isFalse(); assertThat(options.inMutable).isTrue(); @@ -178,49 +179,42 @@ public void setHardwareConfigIfAllowed_withOsLessThanO_returnsFalse_doesNotSetVa boolean result = state.setHardwareConfigIfAllowed( - /*targetWidth=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, - /*targetHeight=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, + /* targetWidth= */ VALID_DIMENSION, + /* targetHeight= */ VALID_DIMENSION, options, - /*isHardwareConfigAllowed=*/ true, - /*isExifOrientationRequired=*/ false); + /* isHardwareConfigAllowed= */ true, + /* isExifOrientationRequired= */ false); assertThat(result).isFalse(); assertThat(options.inMutable).isTrue(); assertThat(options.inPreferredConfig).isNull(); } - @Config(sdk = Build.VERSION_CODES.O) + @Config(sdk = Build.VERSION_CODES.Q) @Test public void - setHardwareConfigIfAllowed_withDisallowedSamsungDevices_returnsFalse_doesNotSetValues() { - for (String model : - new String[] { - "SM-N9351", "SM-J72053", "SM-G9600", "SM-G965ab", "SM-G935.", "SM-G930", "SM-A5204" - }) { - ShadowBuild.setModel(model); - HardwareConfigState state = new HardwareConfigState(); - state.unblockHardwareBitmaps(); - BitmapFactory.Options options = new BitmapFactory.Options(); - options.inPreferredConfig = null; - options.inMutable = true; + setHardwareConfigIfAllowed_withOsQ_beforeUnblockingHardwareBitmaps_returnsTrueAndSetsValues() { + HardwareConfigState state = new HardwareConfigState(); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferredConfig = null; + options.inMutable = true; - boolean result = - state.setHardwareConfigIfAllowed( - /*targetWidth=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, - /*targetHeight=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, - options, - /*isHardwareConfigAllowed=*/ true, - /*isExifOrientationRequired=*/ false); + boolean result = + state.setHardwareConfigIfAllowed( + /* targetWidth= */ VALID_DIMENSION, + /* targetHeight= */ VALID_DIMENSION, + options, + /* isHardwareConfigAllowed= */ true, + /* isExifOrientationRequired= */ false); - assertWithMessage("model: " + model).that(result).isFalse(); - assertWithMessage("model: " + model).that(options.inMutable).isTrue(); - assertWithMessage("model: " + model).that(options.inPreferredConfig).isNull(); - } + assertThat(result).isTrue(); + assertThat(options.inMutable).isFalse(); + assertThat(options.inPreferredConfig).isEqualTo(Bitmap.Config.HARDWARE); } - @Config(sdk = Build.VERSION_CODES.O_MR1) + @Config(sdk = Build.VERSION_CODES.P) @Test - public void setHardwareConfigIfAllowed_withDisallowedSamsungDevices_OMR1_returnsTrue() { + public void setHardwareConfigIfAllowed_withPreviouslyDisallowedSamsungDevices_P_returnsTrue() { for (String model : new String[] { "SM-N9351", "SM-J72053", "SM-G9600", "SM-G965ab", "SM-G935.", "SM-G930", "SM-A5204" @@ -234,11 +228,11 @@ public void setHardwareConfigIfAllowed_withDisallowedSamsungDevices_OMR1_returns boolean result = state.setHardwareConfigIfAllowed( - /*targetWidth=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, - /*targetHeight=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, + /* targetWidth= */ VALID_DIMENSION, + /* targetHeight= */ VALID_DIMENSION, options, - /*isHardwareConfigAllowed=*/ true, - /*isExifOrientationRequired=*/ false); + /* isHardwareConfigAllowed= */ true, + /* isExifOrientationRequired= */ false); assertWithMessage("model: " + model).that(result).isTrue(); assertWithMessage("model: " + model).that(options.inMutable).isFalse(); @@ -248,7 +242,7 @@ public void setHardwareConfigIfAllowed_withDisallowedSamsungDevices_OMR1_returns } } - @Config(sdk = Build.VERSION_CODES.O) + @Config(sdk = Build.VERSION_CODES.P) @Test public void setHardwareConfigIfAllowed_withShortOrEmptyModelNames_returnsTrue() { for (String model : new String[] {".", "-", "", "S", "SM", "SM-", "SM-G", "SM-G9.", "SM-G93"}) { @@ -261,11 +255,11 @@ public void setHardwareConfigIfAllowed_withShortOrEmptyModelNames_returnsTrue() boolean result = state.setHardwareConfigIfAllowed( - /*targetWidth=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, - /*targetHeight=*/ HardwareConfigState.MIN_HARDWARE_DIMENSION_O, + /* targetWidth= */ VALID_DIMENSION, + /* targetHeight= */ VALID_DIMENSION, options, - /*isHardwareConfigAllowed=*/ true, - /*isExifOrientationRequired=*/ false); + /* isHardwareConfigAllowed= */ true, + /* isExifOrientationRequired= */ false); assertWithMessage("model: " + model).that(result).isTrue(); assertWithMessage("model: " + model).that(options.inMutable).isFalse(); diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/ImageReaderTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/ImageReaderTest.java new file mode 100644 index 0000000000..8bfbffe77b --- /dev/null +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/ImageReaderTest.java @@ -0,0 +1,109 @@ +package com.bumptech.glide.load.resource.bitmap; + +import static com.google.common.truth.Truth.assertThat; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import com.bumptech.glide.load.ImageHeaderParser; +import com.bumptech.glide.load.engine.bitmap_recycle.LruArrayPool; +import com.bumptech.glide.testutil.TestResourceUtil; +import com.google.common.io.ByteStreams; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class ImageReaderTest { + + private static final String ROTATED_JPEG_RESOURCE_NAME = "issue387_rotated_jpeg.jpg"; + + private List parsers; + private LruArrayPool byteArrayPool; + + @Before + public void setUp() { + parsers = new ArrayList<>(); + parsers.add(new DefaultImageHeaderParser()); + byteArrayPool = new LruArrayPool(); + } + + @Test + public void testByteBufferReader_heapBuffer_decodesBitmap() throws IOException { + byte[] imageBytes = openResourceBytes(ROTATED_JPEG_RESOURCE_NAME); + ByteBuffer buffer = ByteBuffer.wrap(imageBytes); + ImageReader.ByteBufferReader reader = + new ImageReader.ByteBufferReader(buffer, parsers, byteArrayPool); + + BitmapFactory.Options options = new BitmapFactory.Options(); + Bitmap bitmap = reader.decodeBitmap(options); + + assertThat(bitmap).isNotNull(); + assertThat(bitmap.getWidth()).isGreaterThan(0); + assertThat(bitmap.getHeight()).isGreaterThan(0); + } + + @Test + public void testByteBufferReader_directBuffer_decodesBitmap() throws IOException { + byte[] imageBytes = openResourceBytes(ROTATED_JPEG_RESOURCE_NAME); + ByteBuffer buffer = ByteBuffer.allocateDirect(imageBytes.length); + buffer.put(imageBytes); + buffer.position(0); + ImageReader.ByteBufferReader reader = + new ImageReader.ByteBufferReader(buffer, parsers, byteArrayPool); + + BitmapFactory.Options options = new BitmapFactory.Options(); + Bitmap bitmap = reader.decodeBitmap(options); + + assertThat(bitmap).isNotNull(); + assertThat(bitmap.getWidth()).isGreaterThan(0); + assertThat(bitmap.getHeight()).isGreaterThan(0); + } + + @Test + public void testByteBufferReader_sliceBuffer_decodesBitmap() throws IOException { + byte[] imageBytes = openResourceBytes(ROTATED_JPEG_RESOURCE_NAME); + ByteBuffer buffer = ByteBuffer.allocate(imageBytes.length + 10); + buffer.position(5); + buffer.put(imageBytes); + buffer.position(5); + buffer.limit(5 + imageBytes.length); + ByteBuffer slice = buffer.slice(); + ImageReader.ByteBufferReader reader = + new ImageReader.ByteBufferReader(slice, parsers, byteArrayPool); + + BitmapFactory.Options options = new BitmapFactory.Options(); + Bitmap bitmap = reader.decodeBitmap(options); + + assertThat(bitmap).isNotNull(); + assertThat(bitmap.getWidth()).isGreaterThan(0); + assertThat(bitmap.getHeight()).isGreaterThan(0); + } + + @Test + public void testByteBufferReader_experimentDisabled_decodesBitmapWithStream() throws IOException { + byte[] imageBytes = openResourceBytes(ROTATED_JPEG_RESOURCE_NAME); + ByteBuffer buffer = ByteBuffer.wrap(imageBytes); + ImageReader.ByteBufferReader reader = + new ImageReader.ByteBufferReader( + buffer, parsers, byteArrayPool, /* enableDirectByteBufferDecoding= */ false); + + BitmapFactory.Options options = new BitmapFactory.Options(); + Bitmap bitmap = reader.decodeBitmap(options); + + assertThat(bitmap).isNotNull(); + assertThat(bitmap.getWidth()).isGreaterThan(0); + assertThat(bitmap.getHeight()).isGreaterThan(0); + } + + private byte[] openResourceBytes(String resourceName) throws IOException { + try (InputStream is = TestResourceUtil.openResource(getClass(), resourceName)) { + return ByteStreams.toByteArray(is); + } + } +} diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/RecyclableBufferedInputStreamTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/RecyclableBufferedInputStreamTest.java index 659d681160..18315758b5 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/RecyclableBufferedInputStreamTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/RecyclableBufferedInputStreamTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.bitmap; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.doThrow; @@ -22,7 +23,7 @@ // Not required in tests. @SuppressWarnings("ResultOfMethodCallIgnored") @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class RecyclableBufferedInputStreamTest { private static final int DATA_SIZE = 30; diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/TransformationUtilsTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/TransformationUtilsTest.java index 36176b1fc6..dc1d4356b9 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/TransformationUtilsTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/TransformationUtilsTest.java @@ -3,6 +3,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -13,11 +14,13 @@ import static org.mockito.Mockito.when; import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.ColorSpace; import android.graphics.Matrix; -import android.media.ExifInterface; +import android.os.Build.VERSION_CODES; +import androidx.exifinterface.media.ExifInterface; import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; import com.bumptech.glide.tests.Util; -import com.bumptech.glide.util.Preconditions; import com.google.common.collect.Range; import org.junit.Before; import org.junit.Test; @@ -27,14 +30,9 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.Shadows; import org.robolectric.annotation.Config; -import org.robolectric.annotation.Implementation; -import org.robolectric.annotation.Implements; -import org.robolectric.shadows.ShadowBitmap; @RunWith(RobolectricTestRunner.class) -@Config( - sdk = 28, - shadows = {TransformationUtilsTest.AlphaShadowBitmap.class}) +@Config(sdk = 28) public class TransformationUtilsTest { @Mock private BitmapPool bitmapPool; @@ -164,7 +162,7 @@ public void testCenterCropReturnsGivenBitmapIfGivenBitmapExactlyMatchesGivenDime } @Test - @Config(sdk = 19) + @Config(sdk = Config.OLDEST_SDK) public void testFitCenterHandlesBitmapsWithNullConfigs() { Bitmap toFit = Bitmap.createBitmap(100, 100, Bitmap.Config.RGB_565); toFit.setConfig(null); @@ -225,7 +223,7 @@ public void testCenterCropSetsOutBitmapToHaveAlphaIfInBitmapHasAlpha() { } @Test - @Config(sdk = 19) + @Config(sdk = Config.OLDEST_SDK) public void testCenterCropHandlesBitmapsWithNullConfigs() { Bitmap toTransform = Bitmap.createBitmap(100, 100, Bitmap.Config.RGB_565); toTransform.setConfig(null); @@ -356,12 +354,20 @@ public void testGetExifOrientationDegrees() { @Test public void testRotateImage() { Bitmap toRotate = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888); - + toRotate.setPixel(0, 0, Color.BLUE); + toRotate.setPixel(0, 1, Color.RED); Bitmap zero = TransformationUtils.rotateImage(toRotate, 0); assertTrue(toRotate == zero); Bitmap ninety = TransformationUtils.rotateImage(toRotate, 90); - assertThat(Shadows.shadowOf(ninety).getDescription()).contains("rotate=90.0"); + // Checks if native graphics is enabled. + if (System.getProperty("robolectric.graphicsMode", "").equals("NATIVE")) { + assertThat(ninety.getPixel(0, 0)).isEqualTo(Color.RED); + assertThat(ninety.getPixel(1, 0)).isEqualTo(Color.BLUE); + } else { + // Use legacy shadow APIs + assertThat(Shadows.shadowOf(ninety).getDescription()).contains("rotate=90.0"); + } assertEquals(toRotate.getWidth(), toRotate.getHeight()); } @@ -396,16 +402,31 @@ public void testRotateImageExifReturnsGivenBitmapIfOrientationIsInvalid() { } @Test - @Config(sdk = 19) - public void testRotateImageExifHandlesBitmapsWithNullConfigs() { + @Config(sdk = Config.OLDEST_SDK) + public void testRotateImageExif_preservesitmapsWithNullConfigs() { Bitmap toRotate = Bitmap.createBitmap(100, 100, Bitmap.Config.RGB_565); toRotate.setConfig(null); Bitmap rotated = TransformationUtils.rotateImageExif( bitmapPool, toRotate, ExifInterface.ORIENTATION_ROTATE_180); - assertEquals(Bitmap.Config.ARGB_8888, rotated.getConfig()); + assertNull(rotated.getConfig()); } + @Test + @Config(sdk = VERSION_CODES.UPSIDE_DOWN_CAKE) + public void rotateImageExif_preservesColorSpace() { + Bitmap toRotate = Bitmap.createBitmap(200, 100, Bitmap.Config.ARGB_8888); + toRotate.setColorSpace(ColorSpace.get(ColorSpace.Named.DISPLAY_P3)); + + Bitmap rotated = + TransformationUtils.rotateImageExif( + bitmapPool, toRotate, ExifInterface.ORIENTATION_ROTATE_90); + + assertEquals(ColorSpace.get(ColorSpace.Named.DISPLAY_P3), rotated.getColorSpace()); + } + + // TODO: Add gainmap-based tests once Robolectric has sufficient support. + @Test public void testInitializeMatrixSetsScaleIfFlipHorizontal() { Matrix matrix = mock(Matrix.class); @@ -449,16 +470,4 @@ public void testInitializeMatrixSetsRotateOnRotation() { TransformationUtils.initializeMatrixForRotation(ExifInterface.ORIENTATION_ROTATE_270, matrix); verify(matrix).setRotate(-90); } - - @Implements(Bitmap.class) - public static class AlphaShadowBitmap extends ShadowBitmap { - - @Implementation - public static Bitmap createBitmap(int width, int height, Bitmap.Config config) { - // Robolectric doesn't match the framework behavior with null configs, so we have to do so - // here. - Preconditions.checkNotNull("Config must not be null"); - return ShadowBitmap.createBitmap(width, height, config); - } - } } diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/VideoDecoderTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/VideoDecoderTest.java index bce971510c..3d69f6fe56 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/VideoDecoderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/bitmap/VideoDecoderTest.java @@ -3,6 +3,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.never; @@ -12,6 +13,7 @@ import android.graphics.Bitmap; import android.media.MediaMetadataRetriever; import android.os.Build; +import android.os.Build.VERSION_CODES; import android.os.ParcelFileDescriptor; import com.bumptech.glide.load.Options; import com.bumptech.glide.load.engine.Resource; @@ -29,18 +31,23 @@ import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; +import org.robolectric.util.ReflectionHelpers; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 27) +@Config(sdk = VERSION_CODES.O_MR1) public class VideoDecoderTest { @Mock private ParcelFileDescriptor resource; @Mock private VideoDecoder.MediaMetadataRetrieverFactory factory; - @Mock private VideoDecoder.MediaMetadataRetrieverInitializer initializer; + @Mock private VideoDecoder.MediaInitializer initializer; @Mock private MediaMetadataRetriever retriever; @Mock private BitmapPool bitmapPool; private VideoDecoder decoder; private Options options; private int initialSdkVersion; + private String initialMake; + private String initialModel; + private String initialBuildId; + private String initialDevice; @Before public void setup() { @@ -50,11 +57,16 @@ public void setup() { options = new Options(); initialSdkVersion = Build.VERSION.SDK_INT; + initialMake = Build.MANUFACTURER; + initialModel = Build.MODEL; + initialBuildId = Build.ID; + initialDevice = Build.DEVICE; } @After public void tearDown() { Util.setSdkVersionInt(initialSdkVersion); + resetBuildInfo(initialMake, initialModel, initialBuildId, initialDevice); } @Test @@ -67,7 +79,7 @@ public void testReturnsRetrievedFrameForResource() throws IOException { Resource result = Preconditions.checkNotNull(decoder.decode(resource, 100, 100, options)); - verify(initializer).initialize(retriever, resource); + verify(initializer).initializeRetriever(retriever, resource); assertEquals(expected, result.get()); } @@ -82,8 +94,11 @@ public void run() throws IOException { decoder.decode(resource, 1, 2, options); } }); - - verify(retriever).release(); + try { + verify(retriever).release(); + } catch (Exception e) { + // Ignore failures while cleaning up. + } } @Test(expected = IllegalArgumentException.class) @@ -180,4 +195,78 @@ public void decodeFrame_withTargetSizeOriginalHeightOnly_onApi27_doesNotThrow() assertThat(decoder.decode(resource, 100, Target.SIZE_ORIGINAL, options).get()) .isSameInstanceAs(expected); } + + @Test + public void decodeFrame_notArcDeviceButWebm_doesNotInitializeMediaExtractor() throws IOException { + setDevice("notArc"); + when(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE)) + .thenReturn("video/webm"); + when(retriever.getFrameAtTime(-1, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)) + .thenReturn(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)); + + decoder.decode(resource, Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL, options).get(); + + verify(initializer, never()).initializeExtractor(any(), any()); + } + + @Test + public void decodeFrame_arcDeviceButNotWebm_doesNotInitializeMediaExtractor() throws IOException { + setDevice("arc_cheets"); + when(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE)) + .thenReturn("video/mp4"); + when(retriever.getFrameAtTime(-1, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)) + .thenReturn(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)); + + decoder.decode(resource, Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL, options).get(); + + verify(initializer, never()).initializeExtractor(any(), any()); + } + + @Test + public void decodeFrame_arcDeviceAndWebm_initializesMediaExtractor() throws IOException { + setDevice("arc_cheets"); + when(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE)) + .thenReturn("video/webm"); + when(retriever.getFrameAtTime(-1, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)) + .thenReturn(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)); + + decoder.decode(resource, Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL, options).get(); + + verify(initializer).initializeExtractor(any(), any()); + } + + @Test + @Config(sdk = VERSION_CODES.M) + public void isHdr180RotationFixRequired_androidM_returnsFalse() { + assertThat(VideoDecoder.isHdr180RotationFixRequired()).isFalse(); + } + + @Test + @Config(sdk = VERSION_CODES.Q) + public void isHdr180RotationFixRequired_androidQ_returnsFalse() { + assertThat(VideoDecoder.isHdr180RotationFixRequired()).isFalse(); + } + + @Test + @Config(sdk = VERSION_CODES.R) + public void isHdr180RotationFixRequired_androidR_returnsTrue() { + assertThat(VideoDecoder.isHdr180RotationFixRequired()).isTrue(); + } + + @Test + @Config(sdk = VERSION_CODES.S) + public void isHdr180RotationFixRequired_androidS_returnsTrue() { + assertThat(VideoDecoder.isHdr180RotationFixRequired()).isTrue(); + } + + private void resetBuildInfo(String make, String model, String buildId, String device) { + ReflectionHelpers.setStaticField(Build.class, "MANUFACTURER", make); + ReflectionHelpers.setStaticField(Build.class, "MODEL", model); + ReflectionHelpers.setStaticField(Build.class, "ID", buildId); + setDevice(device); + } + + private void setDevice(String device) { + ReflectionHelpers.setStaticField(Build.class, "DEVICE", device); + } } diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/drawable/DrawableResourceTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/drawable/DrawableResourceTest.java index e1826ce95c..c312e0d234 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/drawable/DrawableResourceTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/drawable/DrawableResourceTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.drawable; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertNotEquals; import static org.mockito.Mockito.mock; @@ -18,7 +19,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class DrawableResourceTest { private TestDrawable drawable; private DrawableResource resource; diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/gif/ByteBufferGifDecoderTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/gif/ByteBufferGifDecoderTest.java index 794ad8570f..9d5b413713 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/gif/ByteBufferGifDecoderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/gif/ByteBufferGifDecoderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.gif; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; @@ -8,7 +9,6 @@ import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; import androidx.test.core.app.ApplicationProvider; import com.bumptech.glide.gifdecoder.GifDecoder; @@ -19,7 +19,6 @@ import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; import com.bumptech.glide.load.engine.bitmap_recycle.LruArrayPool; import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser; -import com.bumptech.glide.tests.GlideShadowLooper; import java.io.IOException; import java.nio.ByteBuffer; import java.util.ArrayList; @@ -32,11 +31,9 @@ import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; -import org.robolectric.annotation.LooperMode; -@LooperMode(LEGACY) @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18, shadows = GlideShadowLooper.class) +@Config(sdk = ROBOLECTRIC_SDK) public class ByteBufferGifDecoderTest { private static final byte[] GIF_HEADER = new byte[] {0x47, 0x49, 0x46}; private static final int ARRAY_POOL_SIZE_BYTES = 4 * 1024 * 1024; diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifDrawableResourceTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifDrawableResourceTest.java index b72af39106..c2b3a102b6 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifDrawableResourceTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifDrawableResourceTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.gif; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -13,7 +14,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class GifDrawableResourceTest { private GifDrawable drawable; private GifDrawableResource resource; diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifDrawableTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifDrawableTest.java index 47f6159a5e..015cc786e3 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifDrawableTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifDrawableTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.gif; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -13,7 +14,6 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; import android.app.Application; import android.graphics.Bitmap; @@ -32,13 +32,9 @@ import androidx.test.core.app.ApplicationProvider; import com.bumptech.glide.gifdecoder.GifDecoder; import com.bumptech.glide.load.Transformation; -import com.bumptech.glide.load.resource.gif.GifDrawableTest.BitmapTrackingShadowCanvas; -import com.bumptech.glide.tests.GlideShadowLooper; import com.bumptech.glide.tests.TearDownGlide; import com.bumptech.glide.tests.Util; import com.bumptech.glide.util.Preconditions; -import java.util.HashSet; -import java.util.Set; import org.junit.After; import org.junit.Before; import org.junit.Rule; @@ -49,17 +45,11 @@ import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; -import org.robolectric.annotation.Implementation; -import org.robolectric.annotation.Implements; -import org.robolectric.annotation.LooperMode; import org.robolectric.shadow.api.Shadow; import org.robolectric.shadows.ShadowCanvas; -@LooperMode(LEGACY) @RunWith(RobolectricTestRunner.class) -@Config( - sdk = 18, - shadows = {GlideShadowLooper.class, BitmapTrackingShadowCanvas.class}) +@Config(sdk = ROBOLECTRIC_SDK) public class GifDrawableTest { @Rule public final TearDownGlide tearDownGlide = new TearDownGlide(); @@ -104,15 +94,19 @@ public void tearDown() { Util.setSdkVersionInt(initialSdkVersion); } - // containsExactly doesn't need its return value checked. - @SuppressWarnings("ResultOfMethodCallIgnored") @Test public void testShouldDrawFirstFrameBeforeAnyFrameRead() { Canvas canvas = new Canvas(); drawable.draw(canvas); - BitmapTrackingShadowCanvas shadowCanvas = Shadow.extract(canvas); - assertThat(shadowCanvas.getDrawnBitmaps()).containsExactly(firstFrame); + ShadowCanvas shadowCanvas = Shadow.extract(canvas); + assertThat(shadowCanvas.getDescription()) + .isEqualTo( + "Bitmap (" + + firstFrame.getWidth() + + " x " + + firstFrame.getHeight() + + ") at (0,0) with height=0 and width=0"); } @Test @@ -637,20 +631,4 @@ private void runLoops(int loopCount, int frameCount) { } } } - - /** Keeps track of the set of Bitmaps drawn to the canvas. */ - @Implements(Canvas.class) - public static final class BitmapTrackingShadowCanvas extends ShadowCanvas { - private final Set drawnBitmaps = new HashSet<>(); - - @Implementation - @Override - public void drawBitmap(Bitmap bitmap, Rect src, Rect dst, Paint paint) { - drawnBitmaps.add(bitmap); - } - - private Iterable getDrawnBitmaps() { - return drawnBitmaps; - } - } } diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifDrawableTransformationTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifDrawableTransformationTest.java index 33047862e6..b81e6c1f9c 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifDrawableTransformationTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifDrawableTransformationTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.gif; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.bumptech.glide.tests.Util.mockResource; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -34,7 +35,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class GifDrawableTransformationTest { @Rule public final KeyTester keyTester = new KeyTester(); @Mock private Transformation wrapped; diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifFrameLoaderTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifFrameLoaderTest.java index 2c13c2cfdb..dc9faf286a 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifFrameLoaderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifFrameLoaderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.gif; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -46,7 +47,7 @@ @LooperMode(LEGACY) @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class GifFrameLoaderTest { @Rule public TearDownGlide tearDownGlide = new TearDownGlide(); @@ -195,7 +196,7 @@ public void testOnFrameReadyClearsPreviousFrame() { Request previousRequest = mock(Request.class); previous.setRequest(previousRequest); previous.onResourceReady( - Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888), /*transition=*/ null); + Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888), /* transition= */ null); DelayTarget current = mock(DelayTarget.class); when(current.getResource()).thenReturn(Bitmap.createBitmap(100, 100, Bitmap.Config.RGB_565)); @@ -271,7 +272,7 @@ public void testClearsCompletedLoadOnFrameReadyIfCleared() { @Test public void onFrameReady_whenNotRunning_doesNotClearPreviouslyLoadedImage() { - loader = createGifFrameLoader(/*handler=*/ null); + loader = createGifFrameLoader(/* handler= */ null); DelayTarget loaded = mock(DelayTarget.class); when(loaded.getResource()).thenReturn(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)); loader.onFrameReady(loaded); @@ -286,7 +287,7 @@ public void onFrameReady_whenNotRunning_doesNotClearPreviouslyLoadedImage() { @Test public void onFrameReady_whenNotRunning_clearsPendingFrameOnClear() { - loader = createGifFrameLoader(/*handler=*/ null); + loader = createGifFrameLoader(/* handler= */ null); DelayTarget loaded = mock(DelayTarget.class); when(loaded.getResource()).thenReturn(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)); loader.onFrameReady(loaded); @@ -304,7 +305,7 @@ public void onFrameReady_whenNotRunning_clearsPendingFrameOnClear() { @Test public void onFrameReady_whenNotRunning_clearsOldFrameOnStart() { - loader = createGifFrameLoader(/*handler=*/ null); + loader = createGifFrameLoader(/* handler= */ null); DelayTarget loaded = mock(DelayTarget.class); when(loaded.getResource()).thenReturn(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)); loader.onFrameReady(loaded); @@ -321,7 +322,7 @@ public void onFrameReady_whenNotRunning_clearsOldFrameOnStart() { @Test public void onFrameReady_whenNotRunning_callsFrameReadyWithNewFrameOnStart() { - loader = createGifFrameLoader(/*handler=*/ null); + loader = createGifFrameLoader(/* handler= */ null); DelayTarget loaded = mock(DelayTarget.class); when(loaded.getResource()).thenReturn(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)); loader.onFrameReady(loaded); @@ -340,7 +341,7 @@ public void onFrameReady_whenNotRunning_callsFrameReadyWithNewFrameOnStart() { @Test public void onFrameReady_whenInvisible_setVisibleLater() { - loader = createGifFrameLoader(/*handler=*/ null); + loader = createGifFrameLoader(/* handler= */ null); // The target is invisible at this point. loader.unsubscribe(callback); loader.setNextStartFromFirstFrame(); @@ -352,7 +353,7 @@ public void onFrameReady_whenInvisible_setVisibleLater() { @Test public void startFromFirstFrame_withPendingFrame_clearsPendingFrame() { - loader = createGifFrameLoader(/*handler=*/ null); + loader = createGifFrameLoader(/* handler= */ null); DelayTarget loaded = mock(DelayTarget.class); when(loaded.getResource()).thenReturn(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)); loader.onFrameReady(loaded); @@ -371,7 +372,7 @@ public void startFromFirstFrame_withPendingFrame_clearsPendingFrame() { } private DelayTarget newDelayTarget() { - return new DelayTarget(handler, /*index=*/ 0, /*targetTime=*/ 0); + return new DelayTarget(handler, /* index= */ 0, /* targetTime= */ 0); } @SuppressWarnings("unchecked") diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifFrameResourceDecoderTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifFrameResourceDecoderTest.java index e9fa2d914e..ab1cb63425 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifFrameResourceDecoderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/gif/GifFrameResourceDecoderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.gif; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.mockito.Mockito.mock; @@ -18,7 +19,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class GifFrameResourceDecoderTest { private GifDecoder gifDecoder; private GifFrameResourceDecoder resourceDecoder; diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/gif/StreamGifDecoderTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/gif/StreamGifDecoderTest.java index 75182fbbd8..5ba3cddf96 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/gif/StreamGifDecoderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/gif/StreamGifDecoderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.gif; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import com.bumptech.glide.load.ImageHeaderParser; @@ -21,7 +22,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class StreamGifDecoderTest { private static final byte[] GIF_HEADER = new byte[] {0x47, 0x49, 0x46}; diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/transcode/BitmapBytesTranscoderTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/transcode/BitmapBytesTranscoderTest.java index d180f61967..e8076411c0 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/transcode/BitmapBytesTranscoderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/transcode/BitmapBytesTranscoderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.transcode; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.bumptech.glide.tests.Util.mockResource; import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.verify; @@ -17,7 +18,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class BitmapBytesTranscoderTest { private BitmapBytesTranscoderHarness harness; diff --git a/library/test/src/test/java/com/bumptech/glide/load/resource/transcode/BitmapDrawableTranscoderTest.java b/library/test/src/test/java/com/bumptech/glide/load/resource/transcode/BitmapDrawableTranscoderTest.java index feb8f31a1e..eb70c89912 100644 --- a/library/test/src/test/java/com/bumptech/glide/load/resource/transcode/BitmapDrawableTranscoderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/load/resource/transcode/BitmapDrawableTranscoderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.load.resource.transcode; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.bumptech.glide.tests.Util.mockResource; import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.when; @@ -16,7 +17,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class BitmapDrawableTranscoderTest { private BitmapDrawableTranscoder transcoder; diff --git a/library/test/src/test/java/com/bumptech/glide/manager/DefaultConnectivityMonitorFactoryTest.java b/library/test/src/test/java/com/bumptech/glide/manager/DefaultConnectivityMonitorFactoryTest.java index 8862294b1c..3d1a7b58a2 100644 --- a/library/test/src/test/java/com/bumptech/glide/manager/DefaultConnectivityMonitorFactoryTest.java +++ b/library/test/src/test/java/com/bumptech/glide/manager/DefaultConnectivityMonitorFactoryTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.manager; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.mock; import static org.robolectric.Shadows.shadowOf; @@ -13,7 +14,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class DefaultConnectivityMonitorFactoryTest { private ConnectivityMonitorFactory factory; diff --git a/library/test/src/test/java/com/bumptech/glide/manager/DefaultConnectivityMonitorTest.java b/library/test/src/test/java/com/bumptech/glide/manager/DefaultConnectivityMonitorTest.java index 55a354f671..b206eebf84 100644 --- a/library/test/src/test/java/com/bumptech/glide/manager/DefaultConnectivityMonitorTest.java +++ b/library/test/src/test/java/com/bumptech/glide/manager/DefaultConnectivityMonitorTest.java @@ -9,14 +9,16 @@ import static org.robolectric.annotation.LooperMode.Mode.LEGACY; import android.app.Application; -import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.net.ConnectivityManager; +import android.net.ConnectivityManager.NetworkCallback; +import android.net.Network; import android.net.NetworkInfo; +import android.os.Build; import androidx.test.core.app.ApplicationProvider; import com.bumptech.glide.manager.DefaultConnectivityMonitorTest.PermissionConnectivityManager; -import java.util.List; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -29,11 +31,14 @@ import org.robolectric.annotation.LooperMode; import org.robolectric.shadow.api.Shadow; import org.robolectric.shadows.ShadowConnectivityManager; +import org.robolectric.shadows.ShadowNetwork; import org.robolectric.shadows.ShadowNetworkInfo; @LooperMode(LEGACY) @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18, shadows = PermissionConnectivityManager.class) +@Config( + sdk = {24}, + shadows = PermissionConnectivityManager.class) public class DefaultConnectivityMonitorTest { @Mock private ConnectivityMonitor.ConnectivityListener listener; private DefaultConnectivityMonitor monitor; @@ -43,14 +48,22 @@ public class DefaultConnectivityMonitorTest { public void setUp() { MockitoAnnotations.initMocks(this); monitor = new DefaultConnectivityMonitor(ApplicationProvider.getApplicationContext(), listener); - harness = new ConnectivityHarness(); + harness = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + ? new ConnectivityHarnessPost24() + : new ConnectivityHarnessPre24(); + } + + @After + public void tearDown() { + SingletonConnectivityReceiver.reset(); } @Test public void testRegistersReceiverOnStart() { monitor.onStart(); - assertThat(getConnectivityReceivers()).hasSize(1); + assertThat(harness.getRegisteredReceivers()).isEqualTo(1); } @Test @@ -58,7 +71,7 @@ public void testDoesNotRegisterTwiceOnStart() { monitor.onStart(); monitor.onStart(); - assertThat(getConnectivityReceivers()).hasSize(1); + assertThat(harness.getRegisteredReceivers()).isEqualTo(1); } @Test @@ -66,7 +79,7 @@ public void testUnregistersReceiverOnStop() { monitor.onStart(); monitor.onStop(); - assertThat(getConnectivityReceivers()).isEmpty(); + assertThat(harness.getRegisteredReceivers()).isEqualTo(0); } @Test @@ -74,7 +87,7 @@ public void testHandlesUnregisteringTwiceInARow() { monitor.onStop(); monitor.onStop(); - assertThat(getConnectivityReceivers()).isEmpty(); + assertThat(harness.getRegisteredReceivers()).isEqualTo(0); } @Test @@ -106,7 +119,7 @@ public void testNotifiesListenerIfDisconnectedAndBecomesConnected() { harness.connect(); harness.broadcast(); - verify(listener).onConnectivityChanged(eq(true)); + verify(listener).onConnectivityChanged(true); } @Test @@ -123,7 +136,7 @@ public void testDoesNotNotifyListenerWhenNotRegistered() { @Test public void register_withMissingPermission_doesNotThrow() { - harness.shadowConnectivityManager.isNetworkPermissionGranted = false; + harness.setNetworkPermissionGranted(false); monitor.onStart(); } @@ -131,7 +144,7 @@ public void register_withMissingPermission_doesNotThrow() { @Test public void onReceive_withMissingPermission_doesNotThrow() { monitor.onStart(); - harness.shadowConnectivityManager.isNetworkPermissionGranted = false; + harness.setNetworkPermissionGranted(false); harness.broadcast(); } @@ -139,32 +152,86 @@ public void onReceive_withMissingPermission_doesNotThrow() { public void onReceive_withMissingPermission_previouslyDisconnected_notifiesListenersConnected() { harness.disconnect(); monitor.onStart(); - harness.shadowConnectivityManager.isNetworkPermissionGranted = false; + harness.setNetworkPermissionGranted(false); harness.broadcast(); - verify(listener).onConnectivityChanged(true); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + verify(listener).onConnectivityChanged(true); + } else { + verify(listener, never()).onConnectivityChanged(anyBoolean()); + } } @Test public void onReceive_withMissingPermission_previouslyConnected_doesNotNotifyListeners() { harness.connect(); monitor.onStart(); - harness.shadowConnectivityManager.isNetworkPermissionGranted = false; + harness.setNetworkPermissionGranted(false); harness.broadcast(); verify(listener, never()).onConnectivityChanged(anyBoolean()); } - private List getConnectivityReceivers() { - Intent connectivity = new Intent(ConnectivityManager.CONNECTIVITY_ACTION); - return shadowOf((Application) ApplicationProvider.getApplicationContext()) - .getReceiversForIntent(connectivity); + private interface ConnectivityHarness { + void connect(); + + void disconnect(); + + void broadcast(); + + void setNetworkPermissionGranted(boolean isGranted); + + int getRegisteredReceivers(); + } + + private static final class ConnectivityHarnessPost24 implements ConnectivityHarness { + + private final PermissionConnectivityManager shadowConnectivityManager; + + ConnectivityHarnessPost24() { + ConnectivityManager connectivityManager = + (ConnectivityManager) + ApplicationProvider.getApplicationContext() + .getSystemService(Context.CONNECTIVITY_SERVICE); + shadowConnectivityManager = Shadow.extract(connectivityManager); + } + + @Override + public void connect() { + shadowConnectivityManager.isConnected = true; + } + + @Override + public void disconnect() { + shadowConnectivityManager.isConnected = false; + } + + @Override + public void broadcast() { + for (NetworkCallback callback : shadowConnectivityManager.getNetworkCallbacks()) { + if (shadowConnectivityManager.isConnected) { + callback.onAvailable(null); + } else { + callback.onLost(null); + } + } + } + + @Override + public void setNetworkPermissionGranted(boolean isGranted) { + shadowConnectivityManager.isNetworkPermissionGranted = isGranted; + } + + @Override + public int getRegisteredReceivers() { + return shadowConnectivityManager.getNetworkCallbacks().size(); + } } - private static class ConnectivityHarness { + private static final class ConnectivityHarnessPre24 implements ConnectivityHarness { private final PermissionConnectivityManager shadowConnectivityManager; - public ConnectivityHarness() { + public ConnectivityHarnessPre24() { ConnectivityManager connectivityManager = (ConnectivityManager) ApplicationProvider.getApplicationContext() @@ -172,25 +239,66 @@ public ConnectivityHarness() { shadowConnectivityManager = Shadow.extract(connectivityManager); } - void disconnect() { + @Override + public void disconnect() { shadowConnectivityManager.setActiveNetworkInfo(null); } - void connect() { + @Override + public void connect() { NetworkInfo networkInfo = ShadowNetworkInfo.newInstance(NetworkInfo.DetailedState.CONNECTED, 0, 0, true, true); shadowConnectivityManager.setActiveNetworkInfo(networkInfo); } - void broadcast() { + @Override + public void broadcast() { Intent connected = new Intent(ConnectivityManager.CONNECTIVITY_ACTION); ApplicationProvider.getApplicationContext().sendBroadcast(connected); } + + @Override + public void setNetworkPermissionGranted(boolean isGranted) { + shadowConnectivityManager.isNetworkPermissionGranted = isGranted; + } + + @Override + public int getRegisteredReceivers() { + Intent connectivity = new Intent(ConnectivityManager.CONNECTIVITY_ACTION); + return shadowOf((Application) ApplicationProvider.getApplicationContext()) + .getReceiversForIntent(connectivity) + .size(); + } } @Implements(ConnectivityManager.class) public static final class PermissionConnectivityManager extends ShadowConnectivityManager { private boolean isNetworkPermissionGranted = true; + private boolean isConnected; + + @Implementation + @Override + public Network getActiveNetwork() { + if (isConnected) { + return ShadowNetwork.newInstance(1); + } else { + return null; + } + } + + @Implementation + @Override + protected void registerDefaultNetworkCallback(NetworkCallback networkCallback) { + if (!isNetworkPermissionGranted) { + throw new SecurityException(); + } + super.registerDefaultNetworkCallback(networkCallback); + if (isConnected) { + networkCallback.onAvailable(null); + } else { + networkCallback.onLost(null); + } + } @Implementation @Override diff --git a/library/test/src/test/java/com/bumptech/glide/manager/LifecycleTest.java b/library/test/src/test/java/com/bumptech/glide/manager/LifecycleTest.java deleted file mode 100644 index 93d67468df..0000000000 --- a/library/test/src/test/java/com/bumptech/glide/manager/LifecycleTest.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.bumptech.glide.manager; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -import java.util.ArrayList; -import java.util.List; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; - -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) -public class LifecycleTest { - - private ActivityFragmentLifecycle lifecycle; - private LifecycleListener listener; - - @Before - public void setUp() { - lifecycle = new ActivityFragmentLifecycle(); - listener = mock(LifecycleListener.class); - } - - @Test - public void testNotifiesAddedListenerOnStart() { - lifecycle.addListener(listener); - lifecycle.onStart(); - verify(listener).onStart(); - } - - @Test - public void testNotifiesAddedListenerOfStartIfStarted() { - lifecycle.onStart(); - lifecycle.addListener(listener); - verify(listener).onStart(); - } - - @Test - public void testDoesNotNotifyAddedListenerOfStartIfDestroyed() { - lifecycle.onStart(); - lifecycle.onStop(); - lifecycle.onDestroy(); - lifecycle.addListener(listener); - - verify(listener, never()).onStart(); - } - - @Test - public void testDoesNotNotifyListenerOfStartIfStartedThenStopped() { - lifecycle.onStart(); - lifecycle.onStop(); - lifecycle.addListener(listener); - verify(listener, never()).onStart(); - } - - @Test - public void testNotifiesAddedListenerOnStop() { - lifecycle.onStart(); - lifecycle.addListener(listener); - lifecycle.onStop(); - verify(listener).onStop(); - } - - @Test - public void testNotifiesAddedListenerOfStopIfStopped() { - lifecycle.onStop(); - lifecycle.addListener(listener); - verify(listener).onStop(); - } - - @Test - public void testDoesNotNotifyAddedListenerOfStopIfDestroyed() { - lifecycle.onStart(); - lifecycle.onStop(); - lifecycle.onDestroy(); - lifecycle.addListener(listener); - verify(listener, never()).onStop(); - } - - @Test - public void testDoesNotNotifyListenerOfStopIfStoppedThenStarted() { - lifecycle.onStop(); - lifecycle.onStart(); - lifecycle.addListener(listener); - verify(listener, never()).onStop(); - } - - @Test - public void testNotifiesAddedListenerOnDestroy() { - lifecycle.addListener(listener); - lifecycle.onDestroy(); - verify(listener).onDestroy(); - } - - @Test - public void testNotifiesAddedListenerOfDestroyIfDestroyed() { - lifecycle.onDestroy(); - lifecycle.addListener(listener); - verify(listener).onDestroy(); - } - - @Test - public void testNotifiesMultipleListeners() { - lifecycle.onStart(); - int toNotify = 20; - List listeners = new ArrayList<>(); - for (int i = 0; i < toNotify; i++) { - listeners.add(mock(LifecycleListener.class)); - } - for (LifecycleListener lifecycleListener : listeners) { - lifecycle.addListener(lifecycleListener); - } - lifecycle.onStop(); - lifecycle.onDestroy(); - for (LifecycleListener lifecycleListener : listeners) { - verify(lifecycleListener).onStart(); - verify(lifecycleListener).onStop(); - verify(lifecycleListener).onDestroy(); - } - } -} diff --git a/library/test/src/test/java/com/bumptech/glide/manager/RequestManagerFragmentTest.java b/library/test/src/test/java/com/bumptech/glide/manager/RequestManagerFragmentTest.java deleted file mode 100644 index bc9360234b..0000000000 --- a/library/test/src/test/java/com/bumptech/glide/manager/RequestManagerFragmentTest.java +++ /dev/null @@ -1,272 +0,0 @@ -package com.bumptech.glide.manager; - -import static org.junit.Assert.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -import android.app.Activity; -import androidx.fragment.app.FragmentActivity; -import com.bumptech.glide.RequestManager; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.exceptions.base.MockitoAssertionError; -import org.robolectric.Robolectric; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.android.controller.ActivityController; -import org.robolectric.annotation.Config; - -@RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) -public class RequestManagerFragmentTest { - private static final String TAG = "tag"; - private Harness[] harnesses; - - @Before - public void setUp() { - harnesses = new Harness[] {new RequestManagerHarness(), new SupportRequestManagerHarness()}; - } - - @Test - public void testSupportCanSetAndGetRequestManager() { - runTest( - new TestCase() { - @Override - public void runTest(Harness harness) { - RequestManager manager = mock(RequestManager.class); - harness.setRequestManager(manager); - assertEquals(manager, harness.getManager()); - } - }); - } - - @Test - public void testReturnsLifecycle() { - runTest( - new TestCase() { - @Override - public void runTest(Harness harness) { - assertEquals(harness.getHarnessLifecycle(), harness.getFragmentLifecycle()); - } - }); - } - - @Test - public void testDoesNotAddNullRequestManagerToLifecycleWhenSet() { - runTest( - new TestCase() { - @Override - public void runTest(Harness harness) { - harness.setRequestManager(null); - verify(harness.getHarnessLifecycle(), never()) - .addListener(any(LifecycleListener.class)); - } - }); - } - - @Test - public void testCallsLifecycleStart() { - runTest( - new TestCase() { - @Override - public void runTest(Harness harness) { - harness.getController().start(); - - verify(harness.getHarnessLifecycle()).onStart(); - } - }); - } - - @Test - public void testCallsRequestManagerStop() { - runTest( - new TestCase() { - @Override - public void runTest(Harness harness) { - harness.getController().start().resume().pause().stop(); - - verify(harness.getHarnessLifecycle()).onStop(); - } - }); - } - - @Test - public void testCallsRequestManagerDestroy() { - runTest( - new TestCase() { - @Override - public void runTest(Harness harness) { - harness.getController().start().resume().pause().stop().destroy(); - - verify(harness.getHarnessLifecycle()).onDestroy(); - } - }); - } - - @Test - public void testOnLowMemoryCallOnNullRequestManagerDoesNotCrash() { - runTest( - new TestCase() { - @Override - public void runTest(Harness harness) { - harness.onLowMemory(); - } - }); - } - - @Test - public void testOnTrimMemoryCallOnNullRequestManagerDoesNotCrash() { - runTest( - new TestCase() { - @Override - public void runTest(Harness harness) { - harness.onTrimMemory(100 /*level*/); - } - }); - } - - private void runTest(TestCase testCase) { - for (Harness harness : harnesses) { - try { - testCase.runTest(harness); - } catch (MockitoAssertionError e) { - throw new Error("Failed to get expected call on " + harness, e); - } - } - } - - private interface TestCase { - void runTest(Harness harness); - } - - private interface Harness { - RequestManager getManager(); - - void setRequestManager(RequestManager manager); - - ActivityFragmentLifecycle getHarnessLifecycle(); - - ActivityFragmentLifecycle getFragmentLifecycle(); - - ActivityController getController(); - - void onLowMemory(); - - void onTrimMemory(@SuppressWarnings("SameParameterValue") int level); - } - - @SuppressWarnings("deprecation") - private static class RequestManagerHarness implements Harness { - private final ActivityController controller; - private final RequestManagerFragment fragment; - private final ActivityFragmentLifecycle lifecycle = mock(ActivityFragmentLifecycle.class); - - public RequestManagerHarness() { - fragment = new RequestManagerFragment(lifecycle); - controller = Robolectric.buildActivity(Activity.class).create(); - controller.get().getFragmentManager().beginTransaction().add(fragment, TAG).commit(); - controller.get().getFragmentManager().executePendingTransactions(); - } - - @Override - public String toString() { - return "DefaultHarness"; - } - - @Override - public RequestManager getManager() { - return fragment.getRequestManager(); - } - - @Override - public void setRequestManager(RequestManager requestManager) { - fragment.setRequestManager(requestManager); - } - - @Override - public ActivityFragmentLifecycle getHarnessLifecycle() { - return lifecycle; - } - - @Override - public ActivityFragmentLifecycle getFragmentLifecycle() { - return fragment.getGlideLifecycle(); - } - - @Override - public ActivityController getController() { - return controller; - } - - @Override - public void onLowMemory() { - fragment.onLowMemory(); - } - - @Override - public void onTrimMemory(int level) { - fragment.onTrimMemory(level); - } - } - - private static class SupportRequestManagerHarness implements Harness { - private final SupportRequestManagerFragment supportFragment; - private final ActivityController supportController; - private final ActivityFragmentLifecycle lifecycle = mock(ActivityFragmentLifecycle.class); - - public SupportRequestManagerHarness() { - supportFragment = new SupportRequestManagerFragment(lifecycle); - supportController = Robolectric.buildActivity(FragmentActivity.class).create(); - - supportController - .get() - .getSupportFragmentManager() - .beginTransaction() - .add(supportFragment, TAG) - .commit(); - supportController.get().getSupportFragmentManager().executePendingTransactions(); - } - - @Override - public String toString() { - return "SupportHarness"; - } - - @Override - public RequestManager getManager() { - return supportFragment.getRequestManager(); - } - - @Override - public void setRequestManager(RequestManager manager) { - supportFragment.setRequestManager(manager); - } - - @Override - public ActivityFragmentLifecycle getHarnessLifecycle() { - return lifecycle; - } - - @Override - public ActivityFragmentLifecycle getFragmentLifecycle() { - return supportFragment.getGlideLifecycle(); - } - - @Override - public ActivityController getController() { - return supportController; - } - - @Override - public void onLowMemory() { - supportFragment.onLowMemory(); - } - - @Override - public void onTrimMemory(int level) { - // Do nothing. - } - } -} diff --git a/library/test/src/test/java/com/bumptech/glide/manager/RequestManagerRetrieverTest.java b/library/test/src/test/java/com/bumptech/glide/manager/RequestManagerRetrieverTest.java index ac1a700345..ac390a39c8 100644 --- a/library/test/src/test/java/com/bumptech/glide/manager/RequestManagerRetrieverTest.java +++ b/library/test/src/test/java/com/bumptech/glide/manager/RequestManagerRetrieverTest.java @@ -1,13 +1,15 @@ package com.bumptech.glide.manager; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.bumptech.glide.tests.BackgroundUtil.testInBackground; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; import static org.robolectric.annotation.LooperMode.Mode.LEGACY; import android.app.Activity; @@ -17,17 +19,15 @@ import android.os.Handler; import android.os.Looper; import android.view.LayoutInflater; -import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentController; import androidx.fragment.app.FragmentHostCallback; import androidx.test.core.app.ApplicationProvider; -import com.bumptech.glide.GlideExperiments; +import com.bumptech.glide.Glide; import com.bumptech.glide.RequestManager; import com.bumptech.glide.tests.BackgroundUtil.BackgroundTester; -import com.bumptech.glide.tests.GlideShadowLooper; import com.bumptech.glide.tests.TearDownGlide; import com.bumptech.glide.tests.Util; import org.junit.After; @@ -38,31 +38,27 @@ import org.mockito.Mockito; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; -import org.robolectric.Shadows; import org.robolectric.android.controller.ActivityController; import org.robolectric.annotation.Config; import org.robolectric.annotation.LooperMode; +import org.robolectric.shadows.ShadowLooper; @LooperMode(LEGACY) @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18, shadows = GlideShadowLooper.class) +@Config(sdk = ROBOLECTRIC_SDK) public class RequestManagerRetrieverTest { @Rule public TearDownGlide tearDownGlide = new TearDownGlide(); private static final String PARENT_TAG = "parent"; private Context appContext; - private RetrieverHarness[] harnesses; - private RequestManagerRetriever retriever; private int initialSdkVersion; + private RequestManagerRetriever retriever; @Before public void setUp() { appContext = ApplicationProvider.getApplicationContext(); - retriever = new RequestManagerRetriever(/*factory=*/ null, mock(GlideExperiments.class)); - - harnesses = - new RetrieverHarness[] {new DefaultRetrieverHarness(), new SupportRetrieverHarness()}; + retriever = new RequestManagerRetriever(/* factory= */ null); initialSdkVersion = Build.VERSION.SDK_INT; Util.setSdkVersionInt(18); @@ -72,57 +68,7 @@ public void setUp() { public void tearDown() { Util.setSdkVersionInt(initialSdkVersion); - Shadows.shadowOf(Looper.getMainLooper()).runToEndOfTasks(); - assertThat(retriever.pendingRequestManagerFragments).isEmpty(); - assertThat(retriever.pendingSupportRequestManagerFragments).isEmpty(); - } - - @Test - public void testCreatesNewFragmentIfNoneExists() { - for (RetrieverHarness harness : harnesses) { - harness.doGet(); - - Shadows.shadowOf(Looper.getMainLooper()).runToEndOfTasks(); - assertTrue(harness.hasFragmentWithTag(RequestManagerRetriever.FRAGMENT_TAG)); - } - } - - @Test - public void testReturnsNewManagerIfNoneExists() { - for (RetrieverHarness harness : harnesses) { - assertNotNull(harness.doGet()); - } - } - - @Test - public void testReturnsExistingRequestManagerIfExists() { - for (RetrieverHarness harness : harnesses) { - RequestManager requestManager = mock(RequestManager.class); - - harness.addFragmentWithTag(RequestManagerRetriever.FRAGMENT_TAG, requestManager); - - assertEquals(requestManager, harness.doGet()); - } - } - - @Test - public void testReturnsNewRequestManagerIfFragmentExistsButHasNoRequestManager() { - for (RetrieverHarness harness : harnesses) { - harness.addFragmentWithTag(RequestManagerRetriever.FRAGMENT_TAG, null); - - assertNotNull(harness.doGet()); - } - } - - @Test - public void testSavesNewRequestManagerToFragmentIfCreatesRequestManagerForExistingFragment() { - for (RetrieverHarness harness : harnesses) { - harness.addFragmentWithTag(RequestManagerRetriever.FRAGMENT_TAG, null); - RequestManager first = harness.doGet(); - RequestManager second = harness.doGet(); - - assertEquals(first, second); - } + shadowOf(Looper.getMainLooper()).runToEndOfTasks(); } @Test @@ -174,7 +120,7 @@ public void testSupportCanGetRequestManagerFromFragment() { public void testSupportCanGetRequestManagerFromFragment_nonActivityController() { FragmentController controller = FragmentController.createController(new NonActivityHostCallback(appContext)); - controller.attachHost(/*fragment=*/ null); + controller.attachHost(/* fragment= */ null); controller.dispatchCreate(); controller.dispatchStart(); controller.dispatchResume(); @@ -242,60 +188,21 @@ private void helpTestSupportCanGetRequestManagerFromDetachedFragment() { } @SuppressWarnings("deprecation") - @Test(expected = IllegalArgumentException.class) + @Test public void testThrowsIfFragmentNotAttached() { android.app.Fragment fragment = new android.app.Fragment(); - retriever.get(fragment); + assertThrows(IllegalArgumentException.class, () -> retriever.get(fragment)); } - @Test(expected = NullPointerException.class) + @Test public void testThrowsIfSupportFragmentNotAttached() { Fragment fragment = new Fragment(); - retriever.get(fragment); - } - - @Test(expected = IllegalArgumentException.class) - public void testThrowsIfActivityDestroyed() { - RetrieverHarness harness = new DefaultRetrieverHarness(); - harness.getController().pause().stop().destroy(); - harness.doGet(); - } - - @Test(expected = IllegalArgumentException.class) - public void testThrowsIfFragmentActivityDestroyed() { - RetrieverHarness harness = new SupportRetrieverHarness(); - harness.getController().pause().stop().destroy(); - harness.doGet(); - } - - @Test(expected = IllegalArgumentException.class) - public void testThrowsIfGivenNullContext() { - retriever.get((Context) null); - } - - @Test - public void testChecksIfContextIsFragmentActivity() { - RetrieverHarness harness = new SupportRetrieverHarness(); - RequestManager requestManager = harness.doGet(); - - assertEquals(requestManager, retriever.get((Context) harness.getController().get())); + assertThrows(NullPointerException.class, () -> retriever.get(fragment)); } @Test - public void testChecksIfContextIsActivity() { - RetrieverHarness harness = new DefaultRetrieverHarness(); - RequestManager requestManager = harness.doGet(); - - assertEquals(requestManager, retriever.get((Context) harness.getController().get())); - } - - @Test - public void testHandlesContextWrappersForActivities() { - RetrieverHarness harness = new DefaultRetrieverHarness(); - RequestManager requestManager = harness.doGet(); - ContextWrapper contextWrapper = new ContextWrapper(harness.getController().get()); - - assertEquals(requestManager, retriever.get(contextWrapper)); + public void testThrowsIfGivenNullContext() { + assertThrows(IllegalArgumentException.class, () -> retriever.get((Context) null)); } @Test @@ -310,7 +217,7 @@ public void testHandlesContextWrappersForApplication() { public void testHandlesContextWrapperWithoutApplication() throws Exception { // Create a Context which is not associated with an Application instance. Context baseContext = - appContext.createPackageContext(appContext.getPackageName(), /*flags=*/ 0); + appContext.createPackageContext(appContext.getPackageName(), /* flags= */ 0); // Sanity-check that Robolectric behaves the same as the framework. assertThat(baseContext.getApplicationContext()).isNull(); @@ -370,7 +277,7 @@ public void testCanCallGetInOnAttachToWindowInFragmentInViewPager() { // to the main thread here to work around an issue caused by a recursive method call so we // need (and reasonably // expect) our message to not run immediately - Shadows.shadowOf(Looper.getMainLooper()).pause(); + shadowOf(Looper.getMainLooper()).pause(); Robolectric.buildActivity(Issue117Activity.class).create().start().resume().visible(); } @@ -400,119 +307,56 @@ public void testDoesNotThrowIfAskedToGetManagerForFragmentPreJellyBeanMr1() { assertNotNull(retriever.get(spyFragment)); } - private interface RetrieverHarness { - ActivityController getController(); - - RequestManager doGet(); - - boolean hasFragmentWithTag(String tag); - - void addFragmentWithTag(String tag, RequestManager manager); - } - - final class DefaultRetrieverHarness implements RetrieverHarness { - private final ActivityController controller = - Robolectric.buildActivity(Activity.class); - private final android.app.Fragment parent; - - DefaultRetrieverHarness() { - this.parent = new android.app.Fragment(); - - controller.create(); - controller - .get() - .getFragmentManager() - .beginTransaction() - .add(parent, PARENT_TAG) - .commitAllowingStateLoss(); - controller.get().getFragmentManager().executePendingTransactions(); - controller.start().resume(); - } - - @Override - public ActivityController getController() { - return controller; - } - - @Override - public RequestManager doGet() { - return retriever.get(controller.get()); - } - - @Override - public boolean hasFragmentWithTag(String tag) { - return null - != controller - .get() - .getFragmentManager() - .findFragmentByTag(RequestManagerRetriever.FRAGMENT_TAG); - } + @Test + public void get_beforeActivityIsCreated_returnsSameRequestManagerAsAfterActivityIsCreated() { + ShadowLooper shadowLooper = shadowOf(Looper.getMainLooper()); + shadowLooper.pause(); + ActivityController controller = + Robolectric.buildActivity(FragmentActivity.class); + RequestManager beforeCreateRequestManager = Glide.with(controller.get()); + // Make sure that the activity makes it one frame without being created. + controller.create().start(); + // Simulate finishing at least one frame before the next attempt to get a RequestManager + shadowLooper.runOneTask(); - @SuppressWarnings("deprecation") - @Override - public void addFragmentWithTag(String tag, RequestManager requestManager) { - RequestManagerFragment fragment = new RequestManagerFragment(); - fragment.setRequestManager(requestManager); - controller - .get() - .getFragmentManager() - .beginTransaction() - .add(fragment, RequestManagerRetriever.FRAGMENT_TAG) - .commitAllowingStateLoss(); - controller.get().getFragmentManager().executePendingTransactions(); - } + // Try to get the request manager again. If we've successfully retained the Fragment we wanted + // to add, then we should get the same instance. If we added a new Fragment instance, the + // RequestManager won't match and things will be broken. + RequestManager afterCreateRequestManager = Glide.with(controller.get()); + assertThat(afterCreateRequestManager).isEqualTo(beforeCreateRequestManager); } - public class SupportRetrieverHarness implements RetrieverHarness { - private final ActivityController controller = + @Test + public void get_onDetachedFragment_returnsSameRequestManagerAsAfterFragmentIsAttached() { + ShadowLooper shadowLooper = shadowOf(Looper.getMainLooper()); + shadowLooper.pause(); + ActivityController controller = Robolectric.buildActivity(FragmentActivity.class); - private final Fragment parent; - - public SupportRetrieverHarness() { - this.parent = new Fragment(); - - controller.create(); - controller - .get() - .getSupportFragmentManager() - .beginTransaction() - .add(parent, PARENT_TAG) - .commitAllowingStateLoss(); - controller.get().getSupportFragmentManager().executePendingTransactions(); - controller.start().resume(); - } + controller.create(); - @Override - public ActivityController getController() { - return controller; - } - - @Override - public RequestManager doGet() { - return retriever.get(controller.get()); - } + FragmentActivity fragmentActivity = controller.get(); + Fragment childFragment = new Fragment(); + fragmentActivity + .getSupportFragmentManager() + .beginTransaction() + .add(childFragment, "TEST_TAG") + .commitNow(); + fragmentActivity + .getSupportFragmentManager() + .beginTransaction() + .detach(childFragment) + .commitNow(); - @Override - public boolean hasFragmentWithTag(String tag) { - return controller - .get() - .getSupportFragmentManager() - .findFragmentByTag(RequestManagerRetriever.FRAGMENT_TAG) - != null; - } + RequestManager beforeAttachRequestManager = Glide.with(childFragment); + shadowLooper.runOneTask(); + fragmentActivity + .getSupportFragmentManager() + .beginTransaction() + .attach(childFragment) + .commitNow(); - @Override - public void addFragmentWithTag(String tag, RequestManager manager) { - SupportRequestManagerFragment fragment = new SupportRequestManagerFragment(); - fragment.setRequestManager(manager); - controller - .get() - .getSupportFragmentManager() - .beginTransaction() - .add(fragment, RequestManagerRetriever.FRAGMENT_TAG) - .commitAllowingStateLoss(); - controller.get().getSupportFragmentManager().executePendingTransactions(); - } + RequestManager afterAttachRequestManager = Glide.with(childFragment); + assertThat(afterAttachRequestManager).isEqualTo(beforeAttachRequestManager); } /** Simple callback for creating an Activity-less Fragment host. */ @@ -522,7 +366,7 @@ private final class NonActivityHostCallback private final Context context; NonActivityHostCallback(Context context) { - super(context, new Handler(Looper.getMainLooper()), /*windowAnimations=*/ 0); + super(context, new Handler(Looper.getMainLooper()), /* windowAnimations= */ 0); this.context = context; } @@ -531,7 +375,6 @@ public LayoutInflater onGetLayoutInflater() { return LayoutInflater.from(context).cloneInContext(context); } - @Nullable @Override public RequestManagerRetrieverTest onGetHost() { return RequestManagerRetrieverTest.this; diff --git a/library/test/src/test/java/com/bumptech/glide/module/ManifestParserTest.java b/library/test/src/test/java/com/bumptech/glide/module/ManifestParserTest.java index 1ea69fb662..1c769fb9ff 100644 --- a/library/test/src/test/java/com/bumptech/glide/module/ManifestParserTest.java +++ b/library/test/src/test/java/com/bumptech/glide/module/ManifestParserTest.java @@ -1,13 +1,18 @@ package com.bumptech.glide.module; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import android.content.Context; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; import android.os.Bundle; import androidx.annotation.NonNull; import com.bumptech.glide.Glide; @@ -23,10 +28,11 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) @SuppressWarnings("deprecation") public class ManifestParserTest { private static final String MODULE_VALUE = "GlideModule"; + private static final String PACKAGE_NAME = "com.bumptech.test"; @Mock private Context context; private ManifestParser parser; @@ -38,17 +44,27 @@ public void setUp() throws PackageManager.NameNotFoundException { applicationInfo = new ApplicationInfo(); applicationInfo.metaData = new Bundle(); - String packageName = "com.bumptech.test"; - when(context.getPackageName()).thenReturn(packageName); + when(context.getPackageName()).thenReturn(PACKAGE_NAME); PackageManager pm = mock(PackageManager.class); - when(pm.getApplicationInfo(eq(packageName), eq(PackageManager.GET_META_DATA))) + when(pm.getApplicationInfo(eq(PACKAGE_NAME), eq(PackageManager.GET_META_DATA))) .thenReturn(applicationInfo); when(context.getPackageManager()).thenReturn(pm); parser = new ManifestParser(context); } + // TODO(#4977): Remove this after the bug in Compose's previews is fixed. + @Test + public void parse_withNullApplicationInfo_doesNotThrow() throws NameNotFoundException { + PackageManager pm = mock(PackageManager.class); + when(pm.getApplicationInfo(anyString(), anyInt())).thenReturn(null); + when(context.getPackageManager()).thenReturn(pm); + + parser = new ManifestParser(context); + parser.parse(); + } + @Test public void testParse_returnsEmptyListIfNoModulesListed() { assertThat(parser.parse()).isEmpty(); @@ -78,7 +94,6 @@ public void testParse_withMultipleValidModuleNames_returnsListContainingModules( @Test public void testParse_withValidModuleName_ignoresMetadataWithoutGlideModuleValue() { applicationInfo.metaData.putString(TestModule1.class.getName(), MODULE_VALUE + "test"); - assertThat(parser.parse()).isEmpty(); } @@ -96,9 +111,23 @@ public void testThrows_whenClassInManifestIsNotAModule() { parser.parse(); } - @Test(expected = RuntimeException.class) - public void testThrows_whenPackageNameNotFound() { - when(context.getPackageName()).thenReturn("fakePackageName"); + @Test + public void parse_withNullMetadata_doesNotThrow() throws NameNotFoundException { + PackageManager pm = mock(PackageManager.class); + ApplicationInfo applicationInfo = new ApplicationInfo(); + applicationInfo.metaData = null; + when(pm.getApplicationInfo(eq(PACKAGE_NAME), eq(PackageManager.GET_META_DATA))) + .thenReturn(applicationInfo); + when(context.getPackageManager()).thenReturn(pm); + + parser.parse(); + } + + @Test + public void parse_withMissingName_doesNotThrow() throws NameNotFoundException { + PackageManager pm = mock(PackageManager.class); + doThrow(new NameNotFoundException("name")).when(pm).getApplicationInfo(anyString(), anyInt()); + when(context.getPackageManager()).thenReturn(pm); parser.parse(); } diff --git a/library/test/src/test/java/com/bumptech/glide/request/ErrorRequestCoordinatorTest.java b/library/test/src/test/java/com/bumptech/glide/request/ErrorRequestCoordinatorTest.java index fc0643b6aa..c434e6d06a 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/ErrorRequestCoordinatorTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/ErrorRequestCoordinatorTest.java @@ -204,7 +204,7 @@ public void isCleared_primaryFailed_errorCancelled_returnsTrue() { public void isEquivalentTo() { assertThat(coordinator.isEquivalentTo(primary)).isFalse(); - ErrorRequestCoordinator other = newCoordinator(/*parent=*/ null); + ErrorRequestCoordinator other = newCoordinator(/* parent= */ null); assertThat(coordinator.isEquivalentTo(other)).isFalse(); other.setRequests(primary, primary); @@ -234,11 +234,6 @@ public void canSetImage_withNotFailedPrimary_andNullParent_returnsTrue() { assertThat(coordinator.canSetImage(primary)).isTrue(); } - @Test - public void canSetImage_withError_andNullParent_andNotFailedPrimary_returnsFalse() { - assertThat(coordinator.canSetImage(error)).isFalse(); - } - @Test public void canSetImage_withNotFailedPrimary_parentCanSetImage_returnsTrue() { coordinator = newCoordinator(parent); @@ -309,9 +304,19 @@ public void canNotifyStatusChanged_withError_notFailedPrimary_nullParent_returns } @Test - public void canNotifyStatusChanged_withError_failedPrimary_nullParent_returnsTrue() { + public void + canNotifyStatusChanged_withErrorRequest_failedPrimary_nullParent_errorIsNotFailed_returnsFalse() { coordinator.onRequestFailed(primary); + assertThat(coordinator.canNotifyStatusChanged(error)).isFalse(); + } + + @Test + public void + canNotifyStatusChanged_withErrorRequest_failedPrimary_nullParent_failedError_returnsTrue() { + coordinator.onRequestFailed(primary); + coordinator.onRequestFailed(error); + assertThat(coordinator.canNotifyStatusChanged(error)).isTrue(); } @@ -325,13 +330,26 @@ public void canNotifyStatusChanged_withError_failedPrimary_nonNullParentCantNoti } @Test - public void canNotifyStatusChanged_withError_failedPrimary_nonNullParentCanNotify_returnsTrue() { + public void + canNotifyStatusChanged_withError_failedPrimary_notFailedError_nonNullParentCanNotify_returnsFalse() { coordinator = newCoordinator(parent); coordinator.setRequests(primary, error); coordinator.onRequestFailed(primary); when(parent.canNotifyStatusChanged(coordinator)).thenReturn(true); - assertThat(coordinator.canNotifyStatusChanged(primary)).isTrue(); + assertThat(coordinator.canNotifyStatusChanged(error)).isFalse(); + } + + @Test + public void + canNotifyStatusChanged_withError_failedPrimary_failedError_nonNullParentCanNotify_returnsTrue() { + coordinator = newCoordinator(parent); + coordinator.setRequests(primary, error); + coordinator.onRequestFailed(primary); + when(parent.canNotifyStatusChanged(coordinator)).thenReturn(true); + coordinator.onRequestFailed(error); + + assertThat(coordinator.canNotifyStatusChanged(error)).isTrue(); } @Test @@ -532,9 +550,20 @@ public void canNotifyCleared_errorRequest_nullParent_returnsFalse() { } @Test - public void canNotifyCleared_errorRequest_primaryFailed_nullParent_returnsTrue() { + public void canNotifyCleared_errorRequest_primaryFailed_nullParent_returnsFalse() { coordinator.onRequestFailed(primary); - assertThat(coordinator.canNotifyCleared(error)).isTrue(); + assertThat(coordinator.canNotifyCleared(error)).isFalse(); + } + + @Test + public void + canNotifyCleared_primaryRequest_primaryFailed_nonNullParentCanNotNotify_returnsFalse() { + coordinator = newCoordinator(parent); + coordinator.setRequests(primary, error); + when(parent.canNotifyCleared(coordinator)).thenReturn(false); + coordinator.onRequestFailed(primary); + + assertThat(coordinator.canNotifyCleared(primary)).isFalse(); } @Test @@ -548,20 +577,30 @@ public void canNotifyCleared_errorRequest_primaryFailed_nonNullParentCanNotNotif } @Test - public void canNotifyCleared_errorRequest_primaryFailed_nonNullParentCanNotify_returnsTrue() { + public void canNotifyCleared_errorRequest_primaryFailed_nonNullParentCanNotify_returnsFalse() { coordinator = newCoordinator(parent); coordinator.setRequests(primary, error); when(parent.canNotifyCleared(coordinator)).thenReturn(true); coordinator.onRequestFailed(primary); - assertThat(coordinator.canNotifyCleared(error)).isTrue(); + assertThat(coordinator.canNotifyCleared(error)).isFalse(); + } + + @Test + public void canNotifyCleared_primaryRequest_primaryFailed_nonNullParentCanNotify_returnsTrue() { + coordinator = newCoordinator(parent); + coordinator.setRequests(primary, error); + when(parent.canNotifyCleared(coordinator)).thenReturn(true); + coordinator.onRequestFailed(primary); + + assertThat(coordinator.canNotifyCleared(primary)).isTrue(); } private static ErrorRequestCoordinator newCoordinator() { - return newCoordinator(/*parent=*/ null); + return newCoordinator(/* parent= */ null); } private static ErrorRequestCoordinator newCoordinator(@Nullable RequestCoordinator parent) { - return new ErrorRequestCoordinator(/*requestLock=*/ new Object(), parent); + return new ErrorRequestCoordinator(/* requestLock= */ new Object(), parent); } } diff --git a/library/test/src/test/java/com/bumptech/glide/request/RequestFutureTargetTest.java b/library/test/src/test/java/com/bumptech/glide/request/RequestFutureTargetTest.java index 5d58175c82..cfc1dc87d6 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/RequestFutureTargetTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/RequestFutureTargetTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.request; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -27,7 +28,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class RequestFutureTargetTest { private int width; private int height; @@ -60,9 +61,9 @@ public void testReturnsFalseForDoneBeforeDone() { @Test public void testReturnsTrueFromIsDoneIfDone() { future.onResourceReady( - /*resource=*/ new Object(), - /*model=*/ null, - /*target=*/ future, + /* resource= */ new Object(), + /* model= */ null, + /* target= */ future, DataSource.DATA_DISK_CACHE, true /*isFirstResource*/); assertTrue(future.isDone()); @@ -103,9 +104,9 @@ public void testDoesNotRepeatedlyClearRequestIfCancelledRepeatedly() { @Test public void testDoesNotClearRequestIfCancelledAfterDone() { future.onResourceReady( - /*resource=*/ new Object(), - /*model=*/ null, - /*target=*/ future, + /* resource= */ new Object(), + /* model= */ null, + /* target= */ future, DataSource.DATA_DISK_CACHE, true /*isFirstResource*/); future.cancel(true); @@ -122,9 +123,9 @@ public void testReturnsTrueFromDoneIfCancelled() { @Test public void testReturnsFalseFromIsCancelledIfCancelledAfterDone() { future.onResourceReady( - /*resource=*/ new Object(), - /*model=*/ null, - /*target=*/ future, + /* resource= */ new Object(), + /* model= */ null, + /* target= */ future, DataSource.DATA_DISK_CACHE, true /*isFirstResource*/); future.cancel(true); @@ -141,9 +142,9 @@ public void testReturnsTrueFromCancelIfCancelled() { @Test public void testReturnsFalseFromCancelIfDone() { future.onResourceReady( - /*resource=*/ new Object(), - /*model=*/ null, - /*target=*/ future, + /* resource= */ new Object(), + /* model= */ null, + /* target= */ future, DataSource.DATA_DISK_CACHE, true /*isFirstResource*/); assertFalse(future.cancel(true)); @@ -154,9 +155,9 @@ public void testReturnsResourceOnGetIfAlreadyDone() throws ExecutionException, InterruptedException { Object expected = new Object(); future.onResourceReady( - /*resource=*/ expected, - /*model=*/ null, - /*target=*/ future, + /* resource= */ expected, + /* model= */ null, + /* target= */ future, DataSource.DATA_DISK_CACHE, true /*isFirstResource*/); @@ -168,9 +169,9 @@ public void testReturnsResourceOnGetWithTimeoutIfAlreadyDone() throws InterruptedException, ExecutionException, TimeoutException { Object expected = new Object(); future.onResourceReady( - /*resource=*/ expected, - /*model=*/ null, - /*target=*/ future, + /* resource= */ expected, + /* model= */ null, + /* target= */ future, DataSource.DATA_DISK_CACHE, true /*isFirstResource*/); @@ -194,21 +195,21 @@ public void testThrowsCancellationExceptionIfCancelledBeforeGetWithTimeout() @Test(expected = ExecutionException.class) public void testThrowsExecutionExceptionOnGetIfExceptionBeforeGet() throws ExecutionException, InterruptedException { - future.onLoadFailed(/*e=*/ null, /*model=*/ null, future, /*isFirstResource=*/ true); + future.onLoadFailed(/* e= */ null, /* model= */ null, future, /* isFirstResource= */ true); future.get(); } @Test(expected = ExecutionException.class) public void testThrowsExecutionExceptionOnGetIfExceptionWithNullValueBeforeGet() throws ExecutionException, InterruptedException, TimeoutException { - future.onLoadFailed(/*e=*/ null, /*model=*/ null, future, /*isFirstResource=*/ true); + future.onLoadFailed(/* e= */ null, /* model= */ null, future, /* isFirstResource= */ true); future.get(100, TimeUnit.MILLISECONDS); } @Test(expected = ExecutionException.class) public void testThrowsExecutionExceptionOnGetIfExceptionBeforeGetWithTimeout() throws ExecutionException, InterruptedException, TimeoutException { - future.onLoadFailed(/*e=*/ null, /*model=*/ null, future, /*isFirstResource=*/ true); + future.onLoadFailed(/* e= */ null, /* model= */ null, future, /* isFirstResource= */ true); future.get(100, TimeUnit.MILLISECONDS); } @@ -229,9 +230,9 @@ public void testThrowsExceptionIfGetCalledOnMainThread() public void testGetSucceedsOnMainThreadIfDone() throws ExecutionException, InterruptedException { future = new RequestFutureTarget<>(width, height, true, waiter); future.onResourceReady( - /*resource=*/ new Object(), - /*model=*/ null, - /*target=*/ future, + /* resource= */ new Object(), + /* model= */ null, + /* target= */ future, DataSource.DATA_DISK_CACHE, true /*isFirstResource*/); future.get(); @@ -262,7 +263,7 @@ public void testThrowsExecutionExceptionIfLoadFailsWhileWaiting() @Override public Void answer(InvocationOnMock invocationOnMock) { future.onLoadFailed( - /*e=*/ null, /*model=*/ null, future, /*isFirstResource=*/ true); + /* e= */ null, /* model= */ null, future, /* isFirstResource= */ true); return null; } }) @@ -301,16 +302,16 @@ public void testThrowsAssertionErrorIfFinishesWaitingWithoutTimeoutAndDoesNotRec @Test public void testNotifiesAllWhenLoadFails() { - future.onLoadFailed(/*e=*/ null, /*model=*/ null, future, /*isFirstResource=*/ true); + future.onLoadFailed(/* e= */ null, /* model= */ null, future, /* isFirstResource= */ true); verify(waiter).notifyAll(eq(future)); } @Test public void testNotifiesAllWhenResourceReady() { future.onResourceReady( - /*resource=*/ new Object(), - /*model=*/ null, - /*target=*/ future, + /* resource= */ new Object(), + /* model= */ null, + /* target= */ future, DataSource.DATA_DISK_CACHE, true /*isFirstResource*/); verify(waiter).notifyAll(eq(future)); @@ -339,9 +340,9 @@ public void testReturnsResourceIfReceivedWhileWaiting() @Override public Void answer(InvocationOnMock invocationOnMock) { future.onResourceReady( - /*resource=*/ expected, - /*model=*/ null, - /*target=*/ future, + /* resource= */ expected, + /* model= */ null, + /* target= */ future, DataSource.DATA_DISK_CACHE, true /*isFirstResource*/); return null; diff --git a/library/test/src/test/java/com/bumptech/glide/request/RequestOptionsTest.java b/library/test/src/test/java/com/bumptech/glide/request/RequestOptionsTest.java index 67b4da7a8a..f5c559b792 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/RequestOptionsTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/RequestOptionsTest.java @@ -559,7 +559,30 @@ public void testEqualsHashCode() { Drawable second = new GradientDrawable(); assertThat(first).isNotEqualTo(second); assertThat(Util.bothNullOrEqual(first, second)).isFalse(); + // Make sure we're not equal to any other subclass of RequestOptions. + class FakeOptions extends BaseRequestOptions { + @Override + public boolean equals(Object o) { + return o instanceof FakeOptions && super.equals(o); + } + + // Our class doesn't include any additional properties, so we don't need to modify hashcode, + // but + // keep it here as a reminder in case we add properties. + @SuppressWarnings("PMD.UselessOverridingMethod") + @Override + public int hashCode() { + return super.hashCode(); + } + } new EqualsTester() + .addEqualityGroup( + new RequestOptions(), + new RequestOptions(), + new RequestOptions().skipMemoryCache(false), + new RequestOptions().onlyRetrieveFromCache(false), + new RequestOptions().useUnlimitedSourceGeneratorsPool(false)) + .addEqualityGroup(new FakeOptions(), new FakeOptions()) .addEqualityGroup( new RequestOptions().sizeMultiplier(.7f), new RequestOptions().sizeMultiplier(.7f)) .addEqualityGroup(new RequestOptions().sizeMultiplier(0.8f)) @@ -579,12 +602,6 @@ public void testEqualsHashCode() { .addEqualityGroup(new RequestOptions().fallback(second)) .addEqualityGroup( new RequestOptions().skipMemoryCache(true), new RequestOptions().skipMemoryCache(true)) - .addEqualityGroup( - new RequestOptions(), - new RequestOptions().skipMemoryCache(false), - new RequestOptions().theme(null), - new RequestOptions().onlyRetrieveFromCache(false), - new RequestOptions().useUnlimitedSourceGeneratorsPool(false)) .addEqualityGroup( new RequestOptions().override(100), new RequestOptions().override(100, 100)) .addEqualityGroup( diff --git a/library/test/src/test/java/com/bumptech/glide/request/SingleRequestTest.java b/library/test/src/test/java/com/bumptech/glide/request/SingleRequestTest.java index 123f38bcb2..6f6a4a6765 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/SingleRequestTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/SingleRequestTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.request; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.bumptech.glide.tests.Util.isADataSource; import static com.bumptech.glide.tests.Util.mockResource; import static com.google.common.truth.Truth.assertThat; @@ -33,6 +34,7 @@ import com.bumptech.glide.load.engine.Engine; import com.bumptech.glide.load.engine.GlideException; import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.request.target.CustomTarget; import com.bumptech.glide.request.target.SizeReadyCallback; import com.bumptech.glide.request.target.Target; import com.bumptech.glide.request.transition.Transition; @@ -46,6 +48,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -57,7 +60,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) @SuppressWarnings("rawtypes") public class SingleRequestTest { @@ -258,7 +261,7 @@ public void testIgnoresOnSizeReadyIfNotWaitingForSize() { any(Options.class), anyBoolean(), anyBoolean(), - /*useAnimationPool=*/ anyBoolean(), + /* useAnimationPool= */ anyBoolean(), anyBoolean(), any(ResourceCallback.class), anyExecutor()); @@ -524,6 +527,14 @@ public void testRequestListenerIsCalledWithResourceResult() { verify(listener1) .onResourceReady( eq(builder.result), any(Number.class), isAListTarget(), isADataSource(), anyBoolean()); + verify(listener1) + .onResourceReady( + eq(builder.result), + any(Number.class), + isAListTarget(), + isADataSource(), + anyBoolean(), + eq(isLoadedFromAlternateCacheKey)); } @Test @@ -568,7 +579,7 @@ public void testRequestListenerIsCalledWithLoadedFromMemoryIfLoadCompletesSynchr any(Options.class), anyBoolean(), anyBoolean(), - /*useAnimationPool=*/ anyBoolean(), + /* useAnimationPool= */ anyBoolean(), anyBoolean(), any(ResourceCallback.class), anyExecutor())) @@ -622,6 +633,14 @@ public void testRequestListenerIsCalledWithIsFirstResourceIfNoRequestCoordinator verify(listener1) .onResourceReady( eq(builder.result), any(Number.class), isAListTarget(), isADataSource(), eq(true)); + verify(listener1) + .onResourceReady( + eq(builder.result), + any(Number.class), + isAListTarget(), + isADataSource(), + eq(true), + eq(false)); } @Test @@ -634,6 +653,14 @@ public void testRequestListenerIsCalledWithFirstImageIfRequestCoordinatorReturns verify(listener1) .onResourceReady( eq(builder.result), any(Number.class), isAListTarget(), isADataSource(), eq(true)); + verify(listener1) + .onResourceReady( + eq(builder.result), + any(Number.class), + isAListTarget(), + isADataSource(), + eq(true), + eq(false)); } @Test @@ -647,6 +674,97 @@ public void testRequestListenerIsCalledWithFirstImageIfRequestCoordinatorReturns verify(listener1) .onResourceReady( eq(builder.result), any(Number.class), isAListTarget(), isADataSource(), eq(false)); + verify(listener1) + .onResourceReady( + eq(builder.result), + any(Number.class), + isAListTarget(), + isADataSource(), + eq(false), + eq(false)); + } + + @Test + public void onResourceReady_notifiesRequestCoordinator_beforeCallingRequestListeners() { + AtomicBoolean isRequestCoordinatorVerified = new AtomicBoolean(); + SingleRequest request = + builder + .setTarget(new DoNothingTarget()) + .addRequestListener( + new RequestListener<>() { + @Override + public boolean onLoadFailed( + @Nullable GlideException e, + Object model, + @NonNull Target target, + boolean isFirstResource) { + return false; + } + + @Override + public boolean onResourceReady( + @NonNull List resource, + @NonNull Object model, + Target target, + @NonNull DataSource dataSource, + boolean isFirstResource) { + verify(builder.requestCoordinator).onRequestSuccess(target.getRequest()); + isRequestCoordinatorVerified.set(true); + return false; + } + }) + .build(); + builder.target.setRequest(request); + request.onResourceReady( + builder.resource, DataSource.DATA_DISK_CACHE, /* isLoadedFromAlternateCacheKey= */ false); + + assertThat(isRequestCoordinatorVerified.get()).isTrue(); + } + + @Test + public void onLoadFailed_notifiesRequestCoordinator_beforeCallingRequestListeners() { + AtomicBoolean isRequestCoordinatorVerified = new AtomicBoolean(); + SingleRequest request = + builder + .setTarget(new DoNothingTarget()) + .addRequestListener( + new RequestListener<>() { + @Override + public boolean onLoadFailed( + @Nullable GlideException e, + Object model, + @NonNull Target target, + boolean isFirstResource) { + verify(builder.requestCoordinator).onRequestFailed(target.getRequest()); + isRequestCoordinatorVerified.set(true); + return false; + } + + @Override + public boolean onResourceReady( + @NonNull List resource, + @NonNull Object model, + Target target, + @NonNull DataSource dataSource, + boolean isFirstResource) { + return false; + } + }) + .build(); + builder.target.setRequest(request); + request.onLoadFailed(new GlideException("test")); + + assertThat(isRequestCoordinatorVerified.get()).isTrue(); + } + + // We don't need to clear a resource since we're not using it to being with. + private static final class DoNothingTarget extends CustomTarget { + @Override + public void onResourceReady( + @NonNull List resource, @Nullable Transition transition) {} + + @Override + public void onLoadCleared(@Nullable Drawable placeholder) {} } @Test @@ -663,6 +781,14 @@ public void testRequestListenerIsCalledWithFirstImageIfRequestCoordinatorReturns verify(listener1) .onResourceReady( eq(builder.result), any(Number.class), isAListTarget(), isADataSource(), eq(false)); + verify(listener1) + .onResourceReady( + eq(builder.result), + any(Number.class), + isAListTarget(), + isADataSource(), + eq(false), + eq(true)); } @Test @@ -723,7 +849,7 @@ public void testCallsEngineWithOverrideWidthAndHeightIfSet() { any(Options.class), anyBoolean(), anyBoolean(), - /*useAnimationPool=*/ anyBoolean(), + /* useAnimationPool= */ anyBoolean(), anyBoolean(), any(ResourceCallback.class), anyExecutor()); @@ -760,7 +886,7 @@ public void testCanReRunClearedRequests() { any(Options.class), anyBoolean(), anyBoolean(), - /*useAnimationPool=*/ anyBoolean(), + /* useAnimationPool= */ anyBoolean(), anyBoolean(), any(ResourceCallback.class), anyExecutor())) @@ -806,7 +932,7 @@ public void testDoesNotStartALoadIfOnSizeReadyIsCalledAfterClear() { any(Options.class), anyBoolean(), anyBoolean(), - /*useAnimationPool=*/ anyBoolean(), + /* useAnimationPool= */ anyBoolean(), anyBoolean(), any(ResourceCallback.class), anyExecutor()); @@ -838,7 +964,7 @@ public void testCallsSourceUnlimitedExecutorEngineIfOptionsIsSet() { any(Options.class), anyBoolean(), eq(true), - /*useAnimationPool=*/ anyBoolean(), + /* useAnimationPool= */ anyBoolean(), anyBoolean(), any(ResourceCallback.class), anyExecutor()); @@ -870,7 +996,7 @@ public void testCallsSourceExecutorEngineIfOptionsIsSet() { any(Options.class), anyBoolean(), eq(false), - /*useAnimationPool=*/ anyBoolean(), + /* useAnimationPool= */ anyBoolean(), anyBoolean(), any(ResourceCallback.class), anyExecutor()); @@ -1035,9 +1161,9 @@ SingleRequest build() { .signature(signature) .useUnlimitedSourceGeneratorsPool(useUnlimitedSourceGeneratorsPool); return SingleRequest.obtain( - /*context=*/ glideContext, - /*glideContext=*/ glideContext, - /*requestLock=*/ new Object(), + /* context= */ glideContext, + /* glideContext= */ glideContext, + /* requestLock= */ new Object(), model, transcodeClass, requestOptions, @@ -1045,7 +1171,7 @@ SingleRequest build() { overrideHeight, priority, target, - /*targetListener=*/ null, + /* targetListener= */ null, requestListeners, requestCoordinator, engine, diff --git a/library/test/src/test/java/com/bumptech/glide/request/ThumbnailRequestCoordinatorTest.java b/library/test/src/test/java/com/bumptech/glide/request/ThumbnailRequestCoordinatorTest.java index 29574ec3a3..72fb121a6c 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/ThumbnailRequestCoordinatorTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/ThumbnailRequestCoordinatorTest.java @@ -402,10 +402,10 @@ public void testIsEquivalentTo() { } private static ThumbnailRequestCoordinator newCoordinator() { - return newCoordinator(/*parent=*/ null); + return newCoordinator(/* parent= */ null); } private static ThumbnailRequestCoordinator newCoordinator(RequestCoordinator parent) { - return new ThumbnailRequestCoordinator(/*requestLock=*/ new Object(), parent); + return new ThumbnailRequestCoordinator(/* requestLock= */ new Object(), parent); } } diff --git a/library/test/src/test/java/com/bumptech/glide/request/target/AppWidgetTargetTest.java b/library/test/src/test/java/com/bumptech/glide/request/target/AppWidgetTargetTest.java index b804580c5f..0cc279f44c 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/target/AppWidgetTargetTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/target/AppWidgetTargetTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.request.target; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.eq; @@ -22,7 +23,7 @@ import org.robolectric.shadows.ShadowAppWidgetManager; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18, shadows = AppWidgetTargetTest.UpdateShadowAppWidgetManager.class) +@Config(sdk = ROBOLECTRIC_SDK, shadows = AppWidgetTargetTest.UpdateShadowAppWidgetManager.class) public class AppWidgetTargetTest { private UpdateShadowAppWidgetManager shadowManager; private RemoteViews views; diff --git a/library/test/src/test/java/com/bumptech/glide/request/target/BitmapImageViewTargetTest.java b/library/test/src/test/java/com/bumptech/glide/request/target/BitmapImageViewTargetTest.java index e33f184b71..12bba565da 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/target/BitmapImageViewTargetTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/target/BitmapImageViewTargetTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.request.target; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import android.graphics.Bitmap; @@ -13,7 +14,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class BitmapImageViewTargetTest { private ImageView view; diff --git a/library/test/src/test/java/com/bumptech/glide/request/target/ImageViewTargetFactoryTest.java b/library/test/src/test/java/com/bumptech/glide/request/target/ImageViewTargetFactoryTest.java index 3706e84c9e..d7a32d94d5 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/target/ImageViewTargetFactoryTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/target/ImageViewTargetFactoryTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.request.target; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import android.graphics.Bitmap; @@ -16,7 +17,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class ImageViewTargetFactoryTest { private ImageViewTargetFactory factory; private ImageView view; diff --git a/library/test/src/test/java/com/bumptech/glide/request/target/ImageViewTargetTest.java b/library/test/src/test/java/com/bumptech/glide/request/target/ImageViewTargetTest.java index c225b0ef74..a5b1f956bf 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/target/ImageViewTargetTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/target/ImageViewTargetTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.request.target; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -29,7 +30,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class ImageViewTargetTest { @Mock private AnimatedDrawable animatedDrawable; @@ -123,7 +124,7 @@ public void onResourceReady_withAnimatableResource_startsAnimatableAfterSetResou AnimatedDrawable drawable = mock(AnimatedDrawable.class); ImageView view = mock(ImageView.class); target = new TestTarget(view); - target.onResourceReady(drawable, /*transition=*/ null); + target.onResourceReady(drawable, /* transition= */ null); InOrder order = inOrder(view, drawable); order.verify(view).setImageDrawable(drawable); @@ -132,11 +133,11 @@ public void onResourceReady_withAnimatableResource_startsAnimatableAfterSetResou @Test public void onLoadCleared_withAnimatableDrawable_stopsDrawable() { - target.onResourceReady(animatedDrawable, /*transition=*/ null); + target.onResourceReady(animatedDrawable, /* transition= */ null); verify(animatedDrawable).start(); verify(animatedDrawable, never()).stop(); - target.onLoadCleared(/*placeholder=*/ null); + target.onLoadCleared(/* placeholder= */ null); verify(animatedDrawable).stop(); } diff --git a/library/test/src/test/java/com/bumptech/glide/request/target/NotificationTargetTest.java b/library/test/src/test/java/com/bumptech/glide/request/target/NotificationTargetTest.java index b7e8bebc2b..3201bf7982 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/target/NotificationTargetTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/target/NotificationTargetTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.request.target; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -22,7 +23,9 @@ import org.robolectric.shadows.ShadowNotificationManager; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18, shadows = NotificationTargetTest.UpdateShadowNotificationManager.class) +@Config( + sdk = ROBOLECTRIC_SDK, + shadows = NotificationTargetTest.UpdateShadowNotificationManager.class) public class NotificationTargetTest { private UpdateShadowNotificationManager shadowManager; private RemoteViews remoteViews; diff --git a/library/test/src/test/java/com/bumptech/glide/request/target/PreloadTargetTest.java b/library/test/src/test/java/com/bumptech/glide/request/target/PreloadTargetTest.java index 6c46169510..cf94ccdbca 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/target/PreloadTargetTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/target/PreloadTargetTest.java @@ -1,10 +1,14 @@ package com.bumptech.glide.request.target; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.robolectric.annotation.LooperMode.Mode.LEGACY; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; +import android.os.Looper; import com.bumptech.glide.RequestManager; import com.bumptech.glide.request.Request; import org.junit.Before; @@ -14,11 +18,9 @@ import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; -import org.robolectric.annotation.LooperMode; -@LooperMode(LEGACY) @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class PreloadTargetTest { @Mock private RequestManager requestManager; @@ -26,6 +28,7 @@ public class PreloadTargetTest { @Before public void setUp() { MockitoAnnotations.initMocks(this); + shadowOf(Looper.getMainLooper()).pause(); } @Test @@ -39,13 +42,58 @@ public void testCallsSizeReadyWithGivenDimensions() { verify(cb).onSizeReady(eq(width), eq(height)); } + // This isn't really supposed to happen, but just to double check... @Test - public void testClearsTargetInOnResourceReady() { + public void onResourceReady_withNullRequest_doesNotClearTarget() { + PreloadTarget target = PreloadTarget.obtain(requestManager, 100, 100); + target.setRequest(null); + + callOnResourceReadyAndRunUiRunnables(target); + + verify(requestManager, never()).clear(target); + } + + @Test + public void onResourceReady_withNotYetCompleteRequest_doesNotClearTarget() { + Request request = mock(Request.class); + when(request.isComplete()).thenReturn(false); + + PreloadTarget target = PreloadTarget.obtain(requestManager, 100, 100); + target.setRequest(request); + + callOnResourceReadyAndRunUiRunnables(target); + + verify(requestManager, never()).clear(target); + } + + @Test + public void onResourceReady_withCompleteRequest_postsToClearTarget() { + Request request = mock(Request.class); + when(request.isComplete()).thenReturn(true); + + PreloadTarget target = PreloadTarget.obtain(requestManager, 100, 100); + target.setRequest(request); + + callOnResourceReadyAndRunUiRunnables(target); + + verify(requestManager).clear(target); + } + + @Test + public void onResourceReady_withCompleteRequest_doesNotImmediatelyClearTarget() { Request request = mock(Request.class); + when(request.isComplete()).thenReturn(true); + PreloadTarget target = PreloadTarget.obtain(requestManager, 100, 100); target.setRequest(request); - target.onResourceReady(new Object(), null); - verify(requestManager).clear(eq(target)); + target.onResourceReady(new Object(), /* transition= */ null); + + verify(requestManager, never()).clear(target); + } + + private void callOnResourceReadyAndRunUiRunnables(Target target) { + target.onResourceReady(new Object(), /* transition= */ null); + shadowOf(Looper.getMainLooper()).idle(); } } diff --git a/library/test/src/test/java/com/bumptech/glide/request/target/ViewTargetTest.java b/library/test/src/test/java/com/bumptech/glide/request/target/ViewTargetTest.java index 60adad71db..31b113878d 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/target/ViewTargetTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/target/ViewTargetTest.java @@ -1,7 +1,6 @@ package com.bumptech.glide.request.target; -import static android.view.ViewGroup.LayoutParams; -import static android.view.ViewTreeObserver.OnPreDrawListener; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -20,7 +19,9 @@ import android.view.Display; import android.view.View; import android.view.View.OnAttachStateChangeListener; +import android.view.ViewGroup.LayoutParams; import android.view.ViewTreeObserver; +import android.view.ViewTreeObserver.OnPreDrawListener; import android.view.WindowManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -51,7 +52,7 @@ @RunWith(RobolectricTestRunner.class) @Config( - sdk = 19, + sdk = ROBOLECTRIC_SDK, shadows = { ViewTargetTest.SizedShadowView.class, ViewTargetTest.PreDrawShadowViewTreeObserver.class @@ -248,7 +249,8 @@ public void testSizeCallbacksAreCalledInOrderPreDraw() { target.getSize(cbs[i]); } - int width = 100, height = 111; + int width = 100; + int height = 111; shadowView.setWidth(width).setHeight(height).setIsLaidOut(true); shadowObserver.fireOnPreDrawListeners(); @@ -484,7 +486,7 @@ public void clearOnDetach_onDetach_withRunningRequest_pausesRequestOnce() { @Test public void clearOnDetach_onDetach_afterOnLoadCleared_removesListener() { attachStateTarget.clearOnDetach(); - attachStateTarget.onLoadCleared(/*placeholder=*/ null); + attachStateTarget.onLoadCleared(/* placeholder= */ null); attachStateTarget.setRequest(request); shadowView.callOnDetachedFromWindow(); @@ -501,7 +503,7 @@ public void clearOnDetach_moreThanOnce_registersObserverOnce() { @Test public void clearOnDetach_onDetach_afterMultipleClearOnDetaches_removesListener() { attachStateTarget.clearOnDetach().clearOnDetach().clearOnDetach(); - attachStateTarget.onLoadCleared(/*placeholder=*/ null); + attachStateTarget.onLoadCleared(/* placeholder= */ null); attachStateTarget.setRequest(request); shadowView.callOnDetachedFromWindow(); @@ -552,8 +554,8 @@ public void clearOnDetach_afterLoadClearedAndRestarted_onAttach_beingsRequest() attachStateTarget.clearOnDetach(); attachStateTarget.setRequest(request); when(request.isCleared()).thenReturn(true); - attachStateTarget.onLoadCleared(/*placeholder=*/ null); - attachStateTarget.onLoadStarted(/*placeholder=*/ null); + attachStateTarget.onLoadCleared(/* placeholder= */ null); + attachStateTarget.onLoadStarted(/* placeholder= */ null); shadowView.callOnAttachedToWindow(); verify(request).begin(); @@ -564,7 +566,7 @@ public void clearOnDetach_onAttach_afterLoadCleared_doesNotBeingRequest() { attachStateTarget.clearOnDetach(); attachStateTarget.setRequest(request); when(request.isCleared()).thenReturn(true); - attachStateTarget.onLoadCleared(/*placeholder=*/ null); + attachStateTarget.onLoadCleared(/* placeholder= */ null); shadowView.callOnAttachedToWindow(); verify(request, never()).begin(); @@ -572,7 +574,7 @@ public void clearOnDetach_onAttach_afterLoadCleared_doesNotBeingRequest() { @Test public void onLoadStarted_withoutClearOnDetach_doesNotAddListener() { - attachStateTarget.onLoadStarted(/*placeholder=*/ null); + attachStateTarget.onLoadStarted(/* placeholder= */ null); assertThat(shadowView.attachStateListeners).isEmpty(); } @@ -591,7 +593,7 @@ public void onViewDetachedFromWindow(View v) {} }; shadowView.addOnAttachStateChangeListener(expected); - attachStateTarget.onLoadCleared(/*placeholder=*/ null); + attachStateTarget.onLoadCleared(/* placeholder= */ null); assertThat(shadowView.attachStateListeners).containsExactly(expected); } diff --git a/library/test/src/test/java/com/bumptech/glide/request/transition/DrawableCrossFadeFactoryTest.java b/library/test/src/test/java/com/bumptech/glide/request/transition/DrawableCrossFadeFactoryTest.java index 8ded2f2533..d6a2a1cbe0 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/transition/DrawableCrossFadeFactoryTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/transition/DrawableCrossFadeFactoryTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.request.transition; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; @@ -12,7 +13,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class DrawableCrossFadeFactoryTest { private DrawableCrossFadeFactory factory; diff --git a/library/test/src/test/java/com/bumptech/glide/request/transition/DrawableCrossFadeViewAnimationTest.java b/library/test/src/test/java/com/bumptech/glide/request/transition/DrawableCrossFadeViewAnimationTest.java index a77d1caeee..267342b6fe 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/transition/DrawableCrossFadeViewAnimationTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/transition/DrawableCrossFadeViewAnimationTest.java @@ -1,6 +1,6 @@ package com.bumptech.glide.request.transition; -import static com.bumptech.glide.request.transition.Transition.ViewAdapter; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -11,6 +11,7 @@ import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.TransitionDrawable; +import com.bumptech.glide.request.transition.Transition.ViewAdapter; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -18,7 +19,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class DrawableCrossFadeViewAnimationTest { private CrossFadeHarness harness; diff --git a/library/test/src/test/java/com/bumptech/glide/request/transition/ViewAnimationTest.java b/library/test/src/test/java/com/bumptech/glide/request/transition/ViewAnimationTest.java index b469dcaa5d..406e6fd3ff 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/transition/ViewAnimationTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/transition/ViewAnimationTest.java @@ -1,6 +1,6 @@ package com.bumptech.glide.request.transition; -import static com.bumptech.glide.request.transition.Transition.ViewAdapter; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -11,6 +11,7 @@ import android.content.Context; import android.view.animation.Animation; import android.widget.ImageView; +import com.bumptech.glide.request.transition.Transition.ViewAdapter; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -18,7 +19,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class ViewAnimationTest { private ViewTransition viewAnimation; private ViewAdapter adapter; diff --git a/library/test/src/test/java/com/bumptech/glide/request/transition/ViewPropertyAnimationTest.java b/library/test/src/test/java/com/bumptech/glide/request/transition/ViewPropertyAnimationTest.java index cfd47ea26d..b3c623f8fd 100644 --- a/library/test/src/test/java/com/bumptech/glide/request/transition/ViewPropertyAnimationTest.java +++ b/library/test/src/test/java/com/bumptech/glide/request/transition/ViewPropertyAnimationTest.java @@ -1,6 +1,6 @@ package com.bumptech.glide.request.transition; -import static com.bumptech.glide.request.transition.Transition.ViewAdapter; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -12,6 +12,7 @@ import android.view.View; import android.widget.ImageView; import androidx.test.core.app.ApplicationProvider; +import com.bumptech.glide.request.transition.Transition.ViewAdapter; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -19,7 +20,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class ViewPropertyAnimationTest { private ViewPropertyTransition.Animator animator; private ViewPropertyTransition animation; diff --git a/library/test/src/test/java/com/bumptech/glide/resize/load/ExifTest.java b/library/test/src/test/java/com/bumptech/glide/resize/load/ExifTest.java index 5d0a4cb654..e9af7ec545 100644 --- a/library/test/src/test/java/com/bumptech/glide/resize/load/ExifTest.java +++ b/library/test/src/test/java/com/bumptech/glide/resize/load/ExifTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.resize.load; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; @@ -16,7 +17,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class ExifTest { private ArrayPool byteArrayPool; diff --git a/library/test/src/test/java/com/bumptech/glide/signature/ApplicationVersionSignatureTest.java b/library/test/src/test/java/com/bumptech/glide/signature/ApplicationVersionSignatureTest.java index d4e6f18d46..c78dd0be82 100644 --- a/library/test/src/test/java/com/bumptech/glide/signature/ApplicationVersionSignatureTest.java +++ b/library/test/src/test/java/com/bumptech/glide/signature/ApplicationVersionSignatureTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.signature; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertNotNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -21,7 +22,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class ApplicationVersionSignatureTest { @Rule public final KeyTester keyTester = new KeyTester(); private Context context; @@ -58,7 +59,7 @@ public void testKeyForSignatureIsTheSameAcrossCallsInTheSamePackage() @Test public void testUnresolvablePackageInfo() throws NameNotFoundException { - Context context = mock(Context.class, Answers.RETURNS_DEEP_STUBS.get()); + Context context = mock(Context.class, Answers.RETURNS_DEEP_STUBS); String packageName = "my.package"; when(context.getPackageName()).thenReturn(packageName); when(context.getPackageManager().getPackageInfo(packageName, 0)) @@ -71,7 +72,7 @@ public void testUnresolvablePackageInfo() throws NameNotFoundException { @Test public void testMissingPackageInfo() throws NameNotFoundException { - Context context = mock(Context.class, Answers.RETURNS_DEEP_STUBS.get()); + Context context = mock(Context.class, Answers.RETURNS_DEEP_STUBS); String packageName = "my.package"; when(context.getPackageName()).thenReturn(packageName); when(context.getPackageManager().getPackageInfo(packageName, 0)).thenReturn(null); diff --git a/library/test/src/test/java/com/bumptech/glide/tests/GlideShadowLooper.java b/library/test/src/test/java/com/bumptech/glide/tests/GlideShadowLooper.java deleted file mode 100644 index 760c5a152b..0000000000 --- a/library/test/src/test/java/com/bumptech/glide/tests/GlideShadowLooper.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.bumptech.glide.tests; - -import static org.mockito.Mockito.mock; - -import android.os.Looper; -import android.os.MessageQueue; -import org.robolectric.annotation.Implementation; -import org.robolectric.annotation.Implements; -import org.robolectric.annotation.Resetter; -import org.robolectric.shadows.ShadowLegacyLooper; - -@Implements(Looper.class) -public class GlideShadowLooper extends ShadowLegacyLooper { - public static MessageQueue queue = mock(MessageQueue.class); - - @Implementation - public static MessageQueue myQueue() { - return queue; - } - - @Resetter - @Override - public void reset() { - queue = mock(MessageQueue.class); - } -} diff --git a/library/test/src/test/java/com/bumptech/glide/tests/KeyTester.java b/library/test/src/test/java/com/bumptech/glide/tests/KeyTester.java index 2a7a492f05..b0a46641cd 100644 --- a/library/test/src/test/java/com/bumptech/glide/tests/KeyTester.java +++ b/library/test/src/test/java/com/bumptech/glide/tests/KeyTester.java @@ -136,10 +136,11 @@ protected boolean doEquivalent(@NonNull Key a, @NonNull Key b) { byte[] aDigest = sha256.getDigest(a); byte[] bDigest = sha256.getDigest(b); Object object = new Object(); + Object sentinel = null; return a.equals(b) && b.equals(a) - && !a.equals(null) - && !b.equals(null) + && !a.equals(sentinel) + && !b.equals(sentinel) && !a.equals(object) && !b.equals(object) && Arrays.equals(aDigest, bDigest); diff --git a/library/test/src/test/java/com/bumptech/glide/util/ByteBufferUtilTest.java b/library/test/src/test/java/com/bumptech/glide/util/ByteBufferUtilTest.java index 1b8c93665b..97bf0b496a 100644 --- a/library/test/src/test/java/com/bumptech/glide/util/ByteBufferUtilTest.java +++ b/library/test/src/test/java/com/bumptech/glide/util/ByteBufferUtilTest.java @@ -1,7 +1,10 @@ package com.bumptech.glide.util; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; +import com.bumptech.glide.load.engine.bitmap_recycle.ArrayPool; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -12,36 +15,56 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class ByteBufferUtilTest { private static final int BUFFER_SIZE = 16384; @Test - public void testFromStream_small() throws IOException { - testFromStream(4); + public void testFromStream_small_direct() throws IOException { + testFromStream(4, false /* useHeapBuffer */); } @Test - public void testFromStream_empty() throws IOException { - testFromStream(0); + public void testFromStream_small_heap() throws IOException { + testFromStream(4, true /* useHeapBuffer */); } @Test - public void testFromStream_bufferAndAHalf() throws IOException { - testFromStream(BUFFER_SIZE + BUFFER_SIZE / 2); + public void testFromStream_empty_direct() throws IOException { + testFromStream(0, false /* useHeapBuffer */); } @Test - public void testFromStream_massive() throws IOException { - testFromStream(12 * BUFFER_SIZE + 12345); + public void testFromStream_empty_heap() throws IOException { + testFromStream(0, true /* useHeapBuffer */); } - /** All tests are basically the same thing but with different amounts of data. */ - private void testFromStream(int dataLength) throws IOException { + @Test + public void testFromStream_bufferAndAHalf_direct() throws IOException { + testFromStream(BUFFER_SIZE + BUFFER_SIZE / 2, false /* useHeapBuffer */); + } + + @Test + public void testFromStream_bufferAndAHalf_heap() throws IOException { + testFromStream(BUFFER_SIZE + BUFFER_SIZE / 2, true /* useHeapBuffer */); + } + + @Test + public void testFromStream_massive_direct() throws IOException { + testFromStream(12 * BUFFER_SIZE + 12345, false /* useHeapBuffer */); + } + + @Test + public void testFromStream_massive_heap() throws IOException { + testFromStream(12 * BUFFER_SIZE + 12345, true /* useHeapBuffer */); + } + + private void testFromStream(int dataLength, boolean useHeapBuffer) throws IOException { byte[] bytes = createByteData(dataLength); InputStream byteStream = new ByteArrayInputStream(bytes); - ByteBuffer byteBuffer = ByteBufferUtil.fromStream(byteStream); + ByteBuffer byteBuffer = ByteBufferUtil.fromStream(byteStream, useHeapBuffer); assertByteBufferContents(byteBuffer, bytes); + assertEquals(useHeapBuffer, !byteBuffer.isDirect()); byteStream.close(); } @@ -62,4 +85,74 @@ private void assertByteBufferContents(ByteBuffer buffer, byte[] expectedBytes) { assertEquals(expectedBytes[i], buffer.get(i)); } } + + @Test + public void testFromStream_exceptionDuringRead_recyclesBuffers() { + FakeArrayPool pool = new FakeArrayPool(); + InputStream stream = + new InputStream() { + int readCount = 0; + + @Override + public int read() throws IOException { + throw new IOException("Failed!"); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + readCount++; + if (readCount > 1) { + throw new IOException("Failed on second read!"); + } + return len; + } + }; + + try { + ByteBufferUtil.fromStream(stream, /* useHeapBuffer= */ true, pool); + fail("Expected IOException"); + } catch (IOException e) { + // expected + } + + assertEquals(pool.getCalls, pool.putCalls); + assertEquals(2, pool.getCalls); + } + + private static class FakeArrayPool implements ArrayPool { + int getCalls = 0; + int putCalls = 0; + + @Override + public void put(T array) { + putCalls++; + } + + @Deprecated + @Override + public void put(T array, Class arrayClass) { + put(array); + } + + @SuppressWarnings("unchecked") + @Override + public T get(int size, Class arrayClass) { + getCalls++; + if (arrayClass.equals(byte[].class)) { + return (T) new byte[size]; + } + throw new IllegalArgumentException(); + } + + @Override + public T getExact(int size, Class arrayClass) { + return get(size, arrayClass); + } + + @Override + public void clearMemory() {} + + @Override + public void trimMemory(int level) {} + } } diff --git a/library/test/src/test/java/com/bumptech/glide/util/ContentLengthInputStreamTest.java b/library/test/src/test/java/com/bumptech/glide/util/ContentLengthInputStreamTest.java index 24bfcb60e6..1d943355e2 100644 --- a/library/test/src/test/java/com/bumptech/glide/util/ContentLengthInputStreamTest.java +++ b/library/test/src/test/java/com/bumptech/glide/util/ContentLengthInputStreamTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.util; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; @@ -18,7 +19,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class ContentLengthInputStreamTest { @Mock private InputStream wrapped; diff --git a/library/test/src/test/java/com/bumptech/glide/util/FixedPreloadSizeProviderTest.java b/library/test/src/test/java/com/bumptech/glide/util/FixedPreloadSizeProviderTest.java index 3600ab841e..46ba8f7a18 100644 --- a/library/test/src/test/java/com/bumptech/glide/util/FixedPreloadSizeProviderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/util/FixedPreloadSizeProviderTest.java @@ -1,5 +1,6 @@ package com.bumptech.glide.util; +import static com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK; import static com.google.common.truth.Truth.assertThat; import org.junit.Test; @@ -8,7 +9,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = ROBOLECTRIC_SDK) public class FixedPreloadSizeProviderTest { // containsExactly doesn't need a return value check. diff --git a/library/test/src/test/java/com/bumptech/glide/util/ViewPreloadSizeProviderTest.java b/library/test/src/test/java/com/bumptech/glide/util/ViewPreloadSizeProviderTest.java index ff0b138710..3a998b7f75 100644 --- a/library/test/src/test/java/com/bumptech/glide/util/ViewPreloadSizeProviderTest.java +++ b/library/test/src/test/java/com/bumptech/glide/util/ViewPreloadSizeProviderTest.java @@ -13,7 +13,7 @@ import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = com.bumptech.glide.RobolectricConstants.ROBOLECTRIC_SDK) public class ViewPreloadSizeProviderTest { private View view; diff --git a/library/test/src/test/resources/animated_avif.avif b/library/test/src/test/resources/animated_avif.avif new file mode 100644 index 0000000000..0ea6dd1718 Binary files /dev/null and b/library/test/src/test/resources/animated_avif.avif differ diff --git a/library/test/src/test/resources/animated_webp.webp b/library/test/src/test/resources/animated_webp.webp new file mode 100644 index 0000000000..2d28dbfd38 Binary files /dev/null and b/library/test/src/test/resources/animated_webp.webp differ diff --git a/library/test/src/test/resources/robolectric.properties b/library/test/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..189df8cfae --- /dev/null +++ b/library/test/src/test/resources/robolectric.properties @@ -0,0 +1,4 @@ +# Cap Robolectric target SDK to 34 because the active Robolectric 4.11.1 version +# in this project only supports simulation up to Android SDK 34 (maxSdkVersion=34). +# Using targetSdkVersion 35/36 causes sandbox initialization failures and GHA CI network hangs. +sdk=34 diff --git a/library/test/src/test/resources/small_gainmap_image.jpg b/library/test/src/test/resources/small_gainmap_image.jpg new file mode 100644 index 0000000000..1695b03b0e Binary files /dev/null and b/library/test/src/test/resources/small_gainmap_image.jpg differ diff --git a/mocks/build.gradle b/mocks/build.gradle deleted file mode 100644 index bdb62aec17..0000000000 --- a/mocks/build.gradle +++ /dev/null @@ -1,26 +0,0 @@ -apply plugin: 'com.android.library' - -dependencies { - implementation project(':library') - implementation "androidx.annotation:annotation:${ANDROID_X_ANNOTATION_VERSION}" - implementation "com.google.guava:guava:${GUAVA_VERSION}" - implementation "org.mockito:mockito-core:${MOCKITO_VERSION}" -} - -android { - compileSdkVersion COMPILE_SDK_VERSION as int - - defaultConfig { - minSdkVersion MIN_SDK_VERSION as int - targetSdkVersion TARGET_SDK_VERSION as int - - versionName = VERSION_NAME as String - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 - } -} - -apply from: "${rootProject.projectDir}/scripts/upload.gradle" diff --git a/mocks/build.gradle.kts b/mocks/build.gradle.kts new file mode 100644 index 0000000000..ad8dd35682 --- /dev/null +++ b/mocks/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "com.bumptech.glide.mocks" + + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation(project(":library")) + implementation(libs.androidx.annotation) + implementation(libs.guava) + implementation(libs.mockito.core) +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") \ No newline at end of file diff --git a/mocks/src/main/AndroidManifest.xml b/mocks/src/main/AndroidManifest.xml deleted file mode 100644 index 35b3d02288..0000000000 --- a/mocks/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/mocks/src/main/java/com/bumptech/glide/load/engine/executor/MockGlideExecutor.java b/mocks/src/main/java/com/bumptech/glide/load/engine/executor/MockGlideExecutor.java index ab35c4e64f..e44e8903ff 100644 --- a/mocks/src/main/java/com/bumptech/glide/load/engine/executor/MockGlideExecutor.java +++ b/mocks/src/main/java/com/bumptech/glide/load/engine/executor/MockGlideExecutor.java @@ -27,7 +27,9 @@ public static GlideExecutor newMainThreadExecutor() { return newTestExecutor(new DirectExecutorService()); } - /** @deprecated Use {@link #newMainThreadExecutor} instead. */ + /** + * @deprecated Use {@link #newMainThreadExecutor} instead. + */ @Deprecated public static GlideExecutor newMainThreadUnlimitedExecutor() { return newMainThreadExecutor(); diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000000..4a6e8bd2f6 --- /dev/null +++ b/renovate.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "semanticCommits": "disabled", + "packageRules": [ + { + "matchUpdateTypes": ["minor", "patch", "pin", "digest"], + "automerge": true, + "automergeType": "pr" + } + ] +} diff --git a/samples/contacturi/build.gradle b/samples/contacturi/build.gradle deleted file mode 100644 index 2b61ea4954..0000000000 --- a/samples/contacturi/build.gradle +++ /dev/null @@ -1,30 +0,0 @@ -apply plugin: 'com.android.application' - -dependencies { - implementation project(':library') - implementation "androidx.appcompat:appcompat:${ANDROID_X_VERSION}" - annotationProcessor project(':annotation:compiler') -} - -android { - compileSdkVersion COMPILE_SDK_VERSION as int - - defaultConfig { - applicationId 'com.bumptech.glide.samples.contacturi' - minSdkVersion MIN_SDK_VERSION as int - targetSdkVersion TARGET_SDK_VERSION as int - - versionCode 1 - versionName '1.0' - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 - } -} - -task run(type: Exec, dependsOn: 'installDebug') { - description 'Installs the APK and runs the main activity: "gradlew :samples:???:run"' - commandLine "${android.sdkDirectory}/platform-tools/adb", 'shell', 'am', 'start', '-n', 'com.bumptech.glide.samples.contacturi/.MainActivity' -} diff --git a/samples/contacturi/build.gradle.kts b/samples/contacturi/build.gradle.kts new file mode 100644 index 0000000000..10a5e821bf --- /dev/null +++ b/samples/contacturi/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("com.android.application") +} + +android { + namespace = "com.bumptech.glide.samples.contacturi" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + + versionCode = 1 + versionName = "1.0" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation(project(":library")) + implementation(libs.androidx.appcompat) + annotationProcessor(project(":annotation:compiler")) +} \ No newline at end of file diff --git a/samples/contacturi/src/main/AndroidManifest.xml b/samples/contacturi/src/main/AndroidManifest.xml index 50cd63461f..474f8658ad 100644 --- a/samples/contacturi/src/main/AndroidManifest.xml +++ b/samples/contacturi/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + @@ -11,7 +10,7 @@ android:theme="@style/Theme.AppCompat" > + android:exported="true"> diff --git a/samples/flickr/build.gradle b/samples/flickr/build.gradle deleted file mode 100644 index 93b9f43bf5..0000000000 --- a/samples/flickr/build.gradle +++ /dev/null @@ -1,36 +0,0 @@ -apply plugin: 'com.android.application' - -dependencies { - implementation project(':library') - implementation(project(':integration:recyclerview')) { - transitive = false - } - annotationProcessor project(':annotation:compiler') - - implementation "androidx.appcompat:appcompat:${ANDROID_X_VERSION}" - implementation "com.android.volley:volley:${VOLLEY_VERSION}" - implementation "androidx.recyclerview:recyclerview:${ANDROID_X_VERSION}" -} - -android { - compileSdkVersion COMPILE_SDK_VERSION as int - - defaultConfig { - applicationId 'com.bumptech.glide.samples.flickr' - minSdkVersion MIN_SDK_VERSION as int - targetSdkVersion TARGET_SDK_VERSION as int - - versionCode 1 - versionName '1.0' - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 - } -} - -task run(type: Exec, dependsOn: 'installDebug') { - description 'Installs the APK and runs the main activity: "gradlew :samples:???:run"' - commandLine "${android.sdkDirectory}/platform-tools/adb", 'shell', 'am', 'start', '-n', 'com.bumptech.glide.samples.flickr/.FlickrSearchActivity' -} diff --git a/samples/flickr/build.gradle.kts b/samples/flickr/build.gradle.kts new file mode 100644 index 0000000000..df11a65fbd --- /dev/null +++ b/samples/flickr/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + id("com.android.application") +} + +android { + namespace = "com.bumptech.glide.samples.flickr" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + + + versionCode = 1 + versionName = "1.0" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation(project(":library")) + + implementation(project(":integration:recyclerview")) { + isTransitive = false + } + + annotationProcessor(project(":annotation:compiler")) + + implementation(libs.androidx.appcompat) + implementation(libs.volley) + implementation(libs.androidx.recyclerview) +} \ No newline at end of file diff --git a/samples/flickr/src/main/AndroidManifest.xml b/samples/flickr/src/main/AndroidManifest.xml index 72dcad1804..6a063a919b 100644 --- a/samples/flickr/src/main/AndroidManifest.xml +++ b/samples/flickr/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> + + + \ No newline at end of file diff --git a/static/logo-styles.css b/static/logo-styles.css new file mode 100644 index 0000000000..90520ba175 --- /dev/null +++ b/static/logo-styles.css @@ -0,0 +1,17 @@ +.library-name a { + position: relative; + --logo-width: 75px; + margin-left: calc(var(--logo-width) + 5px); +} + +.library-name a::before { + content: ''; + background: url("../images/glide_circle_logo.png") center no-repeat; + background-size: contain; + position: absolute; + width: var(--logo-width); + height: 50px; + top: -18px; + left: calc(-1 * var(--logo-width) - 5px); + /* other styles required to make your page pretty */ +} \ No newline at end of file diff --git a/testutil/build.gradle b/testutil/build.gradle deleted file mode 100644 index e2c77440d4..0000000000 --- a/testutil/build.gradle +++ /dev/null @@ -1,5 +0,0 @@ -apply plugin: 'java' - -dependencies { - compile "com.google.truth:truth:${TRUTH_VERSION}" -} diff --git a/testutil/build.gradle.kts b/testutil/build.gradle.kts new file mode 100644 index 0000000000..08aa3aaa29 --- /dev/null +++ b/testutil/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { id("com.android.library") } + +android { + namespace = "com.bumptech.glide.testutil" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { minSdk = libs.versions.min.sdk.version.get().toInt() } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + implementation(libs.truth) + implementation(project(":library")) + api(libs.androidx.annotation) + api(libs.androidx.core) + api(libs.androidx.test.core) +} + +tasks.withType().configureEach { exclude("**/google3/**") } diff --git a/testutil/src/main/java/com/bumptech/glide/RobolectricConstants.java b/testutil/src/main/java/com/bumptech/glide/RobolectricConstants.java new file mode 100644 index 0000000000..27ade8a82e --- /dev/null +++ b/testutil/src/main/java/com/bumptech/glide/RobolectricConstants.java @@ -0,0 +1,6 @@ +package com.bumptech.glide; + +public class RobolectricConstants { + /** The default SDK used for Robolectric tests */ + public static final int ROBOLECTRIC_SDK = 24; +} diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/test/BitmapSubject.java b/testutil/src/main/java/com/bumptech/glide/testutil/BitmapSubject.java similarity index 94% rename from instrumentation/src/androidTest/java/com/bumptech/glide/test/BitmapSubject.java rename to testutil/src/main/java/com/bumptech/glide/testutil/BitmapSubject.java index 4759634d10..2e9fc7f76f 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/test/BitmapSubject.java +++ b/testutil/src/main/java/com/bumptech/glide/testutil/BitmapSubject.java @@ -1,4 +1,4 @@ -package com.bumptech.glide.test; +package com.bumptech.glide.testutil; import static com.google.common.truth.Fact.simpleFact; @@ -9,13 +9,12 @@ import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.core.content.res.ResourcesCompat; -import androidx.test.InstrumentationRegistry; +import androidx.test.core.app.ApplicationProvider; import com.google.common.truth.FailureMetadata; import com.google.common.truth.Subject; import com.google.common.truth.Truth; /** Truth assertions for comparing {@link Bitmap}s. */ -// Test APIs. @SuppressWarnings({"WeakerAccess", "unused", "rawtypes", "unchecked"}) public final class BitmapSubject extends Subject { @@ -64,7 +63,7 @@ private static String getDisplayString(Bitmap bitmap) { } public void sameAs(@DrawableRes int resourceId) { - Context context = InstrumentationRegistry.getTargetContext(); + Context context = ApplicationProvider.getApplicationContext(); Drawable drawable = ResourcesCompat.getDrawable(context.getResources(), resourceId, context.getTheme()); sameAs(drawable); @@ -100,7 +99,6 @@ public void isNotRecycled() { } } - @SuppressWarnings({"unchecked", "ConstantConditions"}) public void sameAs(Drawable other) { if (!(other instanceof BitmapDrawable)) { failWithoutActual(simpleFact("The given expected value was not a BitmapDrawable.")); diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/test/ConcurrencyHelper.java b/testutil/src/main/java/com/bumptech/glide/testutil/ConcurrencyHelper.java similarity index 96% rename from instrumentation/src/androidTest/java/com/bumptech/glide/test/ConcurrencyHelper.java rename to testutil/src/main/java/com/bumptech/glide/testutil/ConcurrencyHelper.java index 754bc973de..971297d276 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/test/ConcurrencyHelper.java +++ b/testutil/src/main/java/com/bumptech/glide/testutil/ConcurrencyHelper.java @@ -1,5 +1,6 @@ -package com.bumptech.glide.test; +package com.bumptech.glide.testutil; +import android.content.Context; import android.graphics.drawable.Drawable; import android.os.Debug; import android.os.Handler; @@ -7,7 +8,7 @@ import android.widget.ImageView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.test.InstrumentationRegistry; +import androidx.test.core.app.ApplicationProvider; import com.bumptech.glide.Glide; import com.bumptech.glide.RequestBuilder; import com.bumptech.glide.request.FutureTarget; @@ -88,8 +89,11 @@ public void clearOnMainThread(final ImageView imageView) { runOnMainThread( new Runnable() { @Override + // Required to avoid a weird emulator issue where the Application passed here is otherwise + // cast to a FragmentActivity... + @SuppressWarnings("cast") public void run() { - Glide.with(InstrumentationRegistry.getTargetContext()).clear(imageView); + Glide.with((Context) ApplicationProvider.getApplicationContext()).clear(imageView); } }); } diff --git a/testutil/src/main/java/com/bumptech/glide/testutil/MockModelLoader.java b/testutil/src/main/java/com/bumptech/glide/testutil/MockModelLoader.java new file mode 100644 index 0000000000..58565c258d --- /dev/null +++ b/testutil/src/main/java/com/bumptech/glide/testutil/MockModelLoader.java @@ -0,0 +1,129 @@ +package com.bumptech.glide.testutil; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.test.core.app.ApplicationProvider; +import com.bumptech.glide.Glide; +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.data.DataFetcher.DataCallback; +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 com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; + +public final class MockModelLoader implements ModelLoader { + private final ModelT model; + private final Class dataClass; + private final ListenableFuture dataFuture; + + @SuppressWarnings("unchecked") + public static void mock(final ModelT model, final DataT data) { + mockAsync(model, (Class) data.getClass(), Futures.immediateFuture(data)); + } + + @SuppressWarnings("unchecked") + public static void mockAsync( + final ModelT model, final Class dataClass, final ListenableFuture dataFuture) { + Context context = ApplicationProvider.getApplicationContext(); + + Glide.get(context) + .getRegistry() + .replace( + (Class) model.getClass(), + dataClass, + new ModelLoaderFactory() { + @NonNull + @Override + public ModelLoader build( + @NonNull MultiModelLoaderFactory multiFactory) { + return new MockModelLoader<>(model, dataClass, dataFuture); + } + + @Override + public void teardown() { + // Do nothing. + } + }); + } + + private MockModelLoader( + ModelT model, Class dataClass, ListenableFuture dataFuture) { + this.model = model; + this.dataClass = dataClass; + this.dataFuture = dataFuture; + } + + @Override + public LoadData buildLoadData( + @NonNull ModelT modelT, int width, int height, @NonNull Options options) { + return new LoadData<>(new ObjectKey(modelT), new MockDataFetcher<>(dataClass, dataFuture)); + } + + @Override + public boolean handles(@NonNull ModelT model) { + return this.model.equals(model); + } + + private static final class MockDataFetcher implements DataFetcher { + + private final ListenableFuture dataFuture; + private final Class dataClass; + + MockDataFetcher(Class dataClass, ListenableFuture dataFuture) { + this.dataClass = dataClass; + this.dataFuture = dataFuture; + } + + @Override + public void loadData( + @NonNull Priority priority, final @NonNull DataCallback callback) { + Futures.addCallback( + dataFuture, + new FutureCallback() { + @Override + public void onSuccess(DataT data) { + callback.onDataReady(data); + } + + @Override + public void onFailure(Throwable t) { + if (t instanceof Exception) { + callback.onLoadFailed((Exception) t); + } else { + callback.onLoadFailed(new Exception(t)); + } + } + }, + MoreExecutors.directExecutor()); + } + + @Override + public void cleanup() { + // Do nothing. + } + + @Override + public void cancel() { + dataFuture.cancel(true); + } + + @NonNull + @Override + public Class getDataClass() { + return dataClass; + } + + @NonNull + @Override + public DataSource getDataSource() { + return DataSource.REMOTE; + } + } +} diff --git a/instrumentation/src/androidTest/java/com/bumptech/glide/test/TearDownGlide.java b/testutil/src/main/java/com/bumptech/glide/testutil/TearDownGlide.java similarity index 50% rename from instrumentation/src/androidTest/java/com/bumptech/glide/test/TearDownGlide.java rename to testutil/src/main/java/com/bumptech/glide/testutil/TearDownGlide.java index 1af33d45a1..f402f14ebf 100644 --- a/instrumentation/src/androidTest/java/com/bumptech/glide/test/TearDownGlide.java +++ b/testutil/src/main/java/com/bumptech/glide/testutil/TearDownGlide.java @@ -1,4 +1,4 @@ -package com.bumptech.glide.test; +package com.bumptech.glide.testutil; import android.content.Context; import androidx.test.core.app.ApplicationProvider; @@ -19,22 +19,26 @@ public void evaluate() throws Throwable { try { base.evaluate(); } finally { - new ConcurrencyHelper() - .runOnMainThread( - new Runnable() { - @Override - public void run() { - // Casting to Context explicitly is required on Java8, or the context will - // be interpreted as a FragmentActivity. - RequestManager requestManager = - Glide.with(ApplicationProvider.getApplicationContext()); - requestManager.onStop(); - requestManager.onDestroy(); - } - }); - Glide.tearDown(); + tearDownGlide(); } } }; } + + public void tearDownGlide() { + ConcurrencyHelper concurrencyHelper = new ConcurrencyHelper(); + concurrencyHelper.runOnMainThread( + new Runnable() { + @Override + public void run() { + // Casting to Context explicitly is required on Java8, or the context will + // be interpreted as a FragmentActivity. + RequestManager requestManager = + Glide.with(ApplicationProvider.getApplicationContext()); + requestManager.onStop(); + requestManager.onDestroy(); + } + }); + Glide.tearDown(); + } } diff --git a/testutil/src/main/java/com/bumptech/glide/testutil/TestUtil.java b/testutil/src/main/java/com/bumptech/glide/testutil/TestUtil.java index e34237aa45..7d60a7ede7 100644 --- a/testutil/src/main/java/com/bumptech/glide/testutil/TestUtil.java +++ b/testutil/src/main/java/com/bumptech/glide/testutil/TestUtil.java @@ -37,6 +37,6 @@ public static String isToString(InputStream is) throws IOException { } public static void assertStreamOf(String expected, InputStream result) throws IOException { - assertThat(expected).isEqualTo(isToString(result)); + assertThat(isToString(result)).isEqualTo(expected); } } diff --git a/testutil/src/main/java/com/bumptech/glide/testutil/WaitModelLoader.java b/testutil/src/main/java/com/bumptech/glide/testutil/WaitModelLoader.java new file mode 100644 index 0000000000..ca95702a3a --- /dev/null +++ b/testutil/src/main/java/com/bumptech/glide/testutil/WaitModelLoader.java @@ -0,0 +1,147 @@ +package com.bumptech.glide.testutil; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.core.app.ApplicationProvider; +import com.bumptech.glide.Glide; +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.testutil.WaitModelLoader.WaitModel; +import java.io.InputStream; +import java.util.concurrent.CountDownLatch; + +/** + * Allows callers to load an object but force the load to pause until {@link WaitModel#countDown()} + * is called. + */ +public final class WaitModelLoader implements ModelLoader, DataT> { + + /** + * A Model that can be loaded with Glide where the load will be blocked from completing until + * {@link #countDown()} is called. + * + *

    This class allows us to test what Glide does while a load is in progress. + */ + public static final class WaitModel { + private final CountDownLatch latch = new CountDownLatch(1); + private final ModelT wrapped; + + WaitModel(ModelT wrapped) { + this.wrapped = wrapped; + } + + public void countDown() { + if (latch.getCount() != 1) { + throw new IllegalStateException(); + } + latch.countDown(); + } + } + + /** + * @deprecated Use {@link WaitModelLoaderRule#waitOn(Object)} instead + */ + @Deprecated + public static synchronized WaitModel waitOn(T model) { + @SuppressWarnings("unchecked") + ModelLoaderFactory, InputStream> streamFactory = + new Factory<>((Class) model.getClass(), InputStream.class); + Glide.get(ApplicationProvider.getApplicationContext()) + .getRegistry() + .replace(WaitModel.class, InputStream.class, streamFactory); + + return new WaitModel<>(model); + } + + private final ModelLoader wrapped; + + private WaitModelLoader(ModelLoader wrapped) { + this.wrapped = wrapped; + } + + @Nullable + @Override + public LoadData buildLoadData( + @NonNull WaitModel waitModel, int width, int height, @NonNull Options options) { + LoadData wrappedLoadData = + wrapped.buildLoadData(waitModel.wrapped, width, height, options); + if (wrappedLoadData == null) { + return null; + } + return new LoadData<>( + wrappedLoadData.sourceKey, new WaitFetcher<>(wrappedLoadData.fetcher, waitModel.latch)); + } + + @Override + public boolean handles(@NonNull WaitModel waitModel) { + return wrapped.handles(waitModel.wrapped); + } + + private static final class Factory + implements ModelLoaderFactory, DataT> { + + private final Class modelClass; + private final Class dataClass; + + Factory(Class modelClass, Class dataClass) { + this.modelClass = modelClass; + this.dataClass = dataClass; + } + + @NonNull + @Override + public ModelLoader, DataT> build(MultiModelLoaderFactory multiFactory) { + return new WaitModelLoader<>(multiFactory.build(modelClass, dataClass)); + } + + @Override + public void teardown() { + // Do nothing. + } + } + + private static final class WaitFetcher implements DataFetcher { + + private final DataFetcher wrapped; + private final CountDownLatch toWaitOn; + + WaitFetcher(DataFetcher wrapped, CountDownLatch toWaitOn) { + this.wrapped = wrapped; + this.toWaitOn = toWaitOn; + } + + @Override + public void loadData( + @NonNull Priority priority, @NonNull DataCallback callback) { + ConcurrencyHelper.waitOnLatch(toWaitOn); + wrapped.loadData(priority, callback); + } + + @Override + public void cleanup() { + wrapped.cleanup(); + } + + @Override + public void cancel() { + wrapped.cancel(); + } + + @NonNull + @Override + public Class getDataClass() { + return wrapped.getDataClass(); + } + + @NonNull + @Override + public DataSource getDataSource() { + return wrapped.getDataSource(); + } + } +} diff --git a/testutil/src/main/java/com/bumptech/glide/testutil/WaitModelLoaderRule.java b/testutil/src/main/java/com/bumptech/glide/testutil/WaitModelLoaderRule.java new file mode 100644 index 0000000000..a43514fb7f --- /dev/null +++ b/testutil/src/main/java/com/bumptech/glide/testutil/WaitModelLoaderRule.java @@ -0,0 +1,25 @@ +package com.bumptech.glide.testutil; + +import com.bumptech.glide.testutil.WaitModelLoader.WaitModel; +import java.util.ArrayList; +import java.util.List; +import org.junit.rules.ExternalResource; + +/** Makes sure that all {@link WaitModel}s created by it are unblocked before the test ends. */ +public final class WaitModelLoaderRule extends ExternalResource { + private final List> waitModels = new ArrayList<>(); + + public WaitModel waitOn(T model) { + WaitModel waitModel = WaitModelLoader.waitOn(model); + waitModels.add(waitModel); + return waitModel; + } + + @Override + protected void after() { + super.after(); + for (WaitModel waitModel : waitModels) { + waitModel.countDown(); + } + } +} diff --git a/third_party/disklrucache/README.third_party b/third_party/disklrucache/THIRD_PARTY.md similarity index 100% rename from third_party/disklrucache/README.third_party rename to third_party/disklrucache/THIRD_PARTY.md diff --git a/third_party/disklrucache/build.gradle b/third_party/disklrucache/build.gradle deleted file mode 100644 index 0fb8d75c09..0000000000 --- a/third_party/disklrucache/build.gradle +++ /dev/null @@ -1,40 +0,0 @@ -apply plugin: 'com.android.library' - -repositories { - jcenter() -} - -checkstyle { - toolVersion = "6.6" -} - -checkstyle { - configFile = new File(projectDir, 'checkstyle.xml') -} - -dependencies { - def junitVersion = hasProperty('JUNIT_VERSION') ? JUNIT_VERSION : '4.13.2'; - testImplementation "junit:junit:${junitVersion}" - testImplementation "com.google.truth:truth:${TRUTH_VERSION}" -} - -android { - compileSdkVersion COMPILE_SDK_VERSION as int - - defaultConfig { - minSdkVersion MIN_SDK_VERSION as int - targetSdkVersion TARGET_SDK_VERSION as int - versionName VERSION_NAME as String - consumerProguardFiles 'proguard-rules.txt' - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 - } -} - -def uploaderScript = "${rootProject.projectDir}/scripts/upload.gradle" -if (file(uploaderScript).exists()) { - apply from: uploaderScript -} diff --git a/third_party/disklrucache/build.gradle.kts b/third_party/disklrucache/build.gradle.kts new file mode 100644 index 0000000000..adc66f04e9 --- /dev/null +++ b/third_party/disklrucache/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + id("com.android.library") + checkstyle +} + +checkstyle { + toolVersion = "6.19" + configFile = file("checkstyle.xml") +} + +android { + namespace = "com.bumptech.glide.disklrucache" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation(libs.androidx.annotation) + testImplementation(libs.junit) + testImplementation(libs.truth) +} + +val uploaderScript = "${rootProject.projectDir}/scripts/upload.gradle.kts" + +if (file(uploaderScript).exists()) { + apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") +} \ No newline at end of file diff --git a/third_party/disklrucache/src/main/AndroidManifest.xml b/third_party/disklrucache/src/main/AndroidManifest.xml deleted file mode 100644 index 20eaad4e78..0000000000 --- a/third_party/disklrucache/src/main/AndroidManifest.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/third_party/disklrucache/src/main/java/com/bumptech/glide/disklrucache/DiskLruCache.java b/third_party/disklrucache/src/main/java/com/bumptech/glide/disklrucache/DiskLruCache.java index 2db4ebcfb3..ee157e5267 100644 --- a/third_party/disklrucache/src/main/java/com/bumptech/glide/disklrucache/DiskLruCache.java +++ b/third_party/disklrucache/src/main/java/com/bumptech/glide/disklrucache/DiskLruCache.java @@ -20,6 +20,7 @@ import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; import android.os.StrictMode; +import androidx.annotation.Nullable; import java.io.BufferedWriter; import java.io.Closeable; import java.io.EOFException; @@ -37,6 +38,7 @@ import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; import java.util.concurrent.Callable; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadFactory; @@ -146,6 +148,7 @@ public final class DiskLruCache implements Closeable { private final int appVersion; private long maxSize; private final int valueCount; + private final boolean memoizePathNames; private long size = 0; private Writer journalWriter; private final LinkedHashMap lruEntries = @@ -179,7 +182,8 @@ public Void call() throws Exception { } }; - private DiskLruCache(File directory, int appVersion, int valueCount, long maxSize) { + private DiskLruCache( + File directory, int appVersion, int valueCount, long maxSize, boolean memoizePathNames) { this.directory = directory; this.appVersion = appVersion; this.journalFile = new File(directory, JOURNAL_FILE); @@ -187,19 +191,40 @@ private DiskLruCache(File directory, int appVersion, int valueCount, long maxSiz this.journalFileBackup = new File(directory, JOURNAL_FILE_BACKUP); this.valueCount = valueCount; this.maxSize = maxSize; + this.memoizePathNames = memoizePathNames; } /** - * Opens the cache in {@code directory}, creating a cache if none exists - * there. + * Opens the cache in {@code directory}, creating a cache if none exists there. * * @param directory a writable directory + * @param appVersion the application's current version code * @param valueCount the number of values per cache entry. Must be positive. * @param maxSize the maximum number of bytes this cache should use to store * @throws IOException if reading or writing the cache directory fails */ public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize) throws IOException { + return experimentalOpen( + directory, appVersion, valueCount, maxSize, /* memoizePathNames= */ false); + } + + /** + * Opens the cache in {@code directory}, creating a cache if none exists there, with explicit + * control over path name memoization. + * + *

    Enabling the memoization is a deprecated setting that will be removed in a future version. + * + * @param directory a writable directory + * @param appVersion the application's current version code + * @param valueCount the number of values per cache entry. Must be positive. + * @param maxSize the maximum number of bytes this cache should use to store + * @param memoizePathNames whether to memoize path names + * @return The new disk cache with the given arguments + */ + public static DiskLruCache experimentalOpen( + File directory, int appVersion, int valueCount, long maxSize, boolean memoizePathNames) + throws IOException { if (maxSize <= 0) { throw new IllegalArgumentException("maxSize <= 0"); } @@ -220,7 +245,8 @@ public static DiskLruCache open(File directory, int appVersion, int valueCount, } // Prefer to pick up where we left off. - DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); + DiskLruCache cache = + new DiskLruCache(directory, appVersion, valueCount, maxSize, memoizePathNames); if (cache.journalFile.exists()) { try { cache.readJournal(); @@ -239,7 +265,7 @@ public static DiskLruCache open(File directory, int appVersion, int valueCount, // Create a new empty cache. directory.mkdirs(); - cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); + cache = new DiskLruCache(directory, appVersion, valueCount, maxSize, memoizePathNames); cache.rebuildJournal(); return cache; } @@ -420,7 +446,8 @@ public synchronized Value get(String key) throws IOException { return null; } - for (File file : entry.cleanFiles) { + for (int i = 0; i < valueCount; i++) { + File file = entry.getCleanFile(i); // A file must have been deleted manually! if (!file.exists()) { return null; @@ -740,12 +767,15 @@ public Editor edit() throws IOException { } public File getFile(int index) { + if (files != null) { return files[index]; + } + return new File(directory, key + "." + index); } /** Returns the string value for {@code index}. */ public String getString(int index) throws IOException { - InputStream is = new FileInputStream(files[index]); + InputStream is = new FileInputStream(getFile(index)); return inputStreamToString(is); } @@ -858,9 +888,14 @@ private final class Entry { /** Lengths of this entry's files. */ private final long[] lengths; - /** Memoized File objects for this entry to avoid char[] allocations. */ - File[] cleanFiles; - File[] dirtyFiles; + /** + * Memoized File objects for this entry to avoid char[] allocations. + * + *

    If the disk cache is configured to not memoize path names, these will be null. Otherwise, + * they're always non-null, with one entry per value. + */ + @Nullable File[] cleanFiles; + @Nullable File[] dirtyFiles; /** True if this entry has ever been published. */ private boolean readable; @@ -872,8 +907,14 @@ private final class Entry { private long sequenceNumber; private Entry(String key) { + Objects.requireNonNull(key, "key"); this.key = key; this.lengths = new long[valueCount]; + + if (!memoizePathNames) { + return; + } + cleanFiles = new File[valueCount]; dirtyFiles = new File[valueCount]; @@ -881,11 +922,11 @@ private Entry(String key) { StringBuilder fileBuilder = new StringBuilder(key).append('.'); int truncateTo = fileBuilder.length(); for (int i = 0; i < valueCount; i++) { - fileBuilder.append(i); - cleanFiles[i] = new File(directory, fileBuilder.toString()); - fileBuilder.append(".tmp"); - dirtyFiles[i] = new File(directory, fileBuilder.toString()); - fileBuilder.setLength(truncateTo); + fileBuilder.append(i); + cleanFiles[i] = new File(directory, fileBuilder.toString()); + fileBuilder.append(".tmp"); + dirtyFiles[i] = new File(directory, fileBuilder.toString()); + fileBuilder.setLength(truncateTo); } } @@ -917,11 +958,17 @@ private IOException invalidLengths(String[] strings) throws IOException { } public File getCleanFile(int i) { - return cleanFiles[i]; + if (cleanFiles != null) { + return cleanFiles[i]; + } + return new File(directory, key + "." + i); } public File getDirtyFile(int i) { - return dirtyFiles[i]; + if (dirtyFiles != null) { + return dirtyFiles[i]; + } + return new File(directory, key + "." + i + ".tmp"); } } diff --git a/third_party/disklrucache/src/main/java/com/bumptech/glide/disklrucache/StrictLineReader.java b/third_party/disklrucache/src/main/java/com/bumptech/glide/disklrucache/StrictLineReader.java index 11135db04c..e87288763b 100644 --- a/third_party/disklrucache/src/main/java/com/bumptech/glide/disklrucache/StrictLineReader.java +++ b/third_party/disklrucache/src/main/java/com/bumptech/glide/disklrucache/StrictLineReader.java @@ -68,7 +68,7 @@ class StrictLineReader implements Closeable { * @throws NullPointerException if {@code in} or {@code charset} is null. * @throws IllegalArgumentException if the specified charset is not supported. */ - public StrictLineReader(InputStream in, Charset charset) { + StrictLineReader(InputStream in, Charset charset) { this(in, 8192, charset); } @@ -83,7 +83,7 @@ public StrictLineReader(InputStream in, Charset charset) { * @throws IllegalArgumentException if {@code capacity} is negative or zero * or the specified charset is not supported. */ - public StrictLineReader(InputStream in, int capacity, Charset charset) { + StrictLineReader(InputStream in, int capacity, Charset charset) { if (in == null || charset == null) { throw new NullPointerException(); } @@ -105,6 +105,7 @@ public StrictLineReader(InputStream in, int capacity, Charset charset) { * * @throws IOException for errors when closing the underlying {@code InputStream}. */ + @Override public void close() throws IOException { synchronized (in) { if (buf != null) { @@ -122,7 +123,7 @@ public void close() throws IOException { * @throws IOException for underlying {@code InputStream} errors. * @throws EOFException for the end of source stream. */ - public String readLine() throws IOException { + String readLine() throws IOException { synchronized (in) { if (buf == null) { throw new IOException("LineReader is closed"); @@ -176,7 +177,7 @@ public String toString() { } } - public boolean hasUnterminatedLine() { + boolean hasUnterminatedLine() { return end == -1; } diff --git a/third_party/disklrucache/src/test/java/com/bumptech/glide/disklrucache/DiskLruCacheTest.java b/third_party/disklrucache/src/test/java/com/bumptech/glide/disklrucache/DiskLruCacheTest.java index f3db6e2b5e..12eb938efc 100644 --- a/third_party/disklrucache/src/test/java/com/bumptech/glide/disklrucache/DiskLruCacheTest.java +++ b/third_party/disklrucache/src/test/java/com/bumptech/glide/disklrucache/DiskLruCacheTest.java @@ -524,7 +524,7 @@ public static void setUpClass() { @Test public void readingTheSameFileMultipleTimes() throws Exception { set("a", "a", "b"); DiskLruCache.Value value = cache.get("a"); - assertThat(value.getFile(0)).isSameInstanceAs(value.getFile(0)); + assertThat(value.getFile(0)).isEqualTo(value.getFile(0)); } @Test public void rebuildJournalOnRepeatedReads() throws Exception { diff --git a/third_party/exif_orientation_examples/.gitignore b/third_party/exif_orientation_examples/.gitignore deleted file mode 100644 index f39f9657ce..0000000000 --- a/third_party/exif_orientation_examples/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -/.ruby-gemset -/.ruby-version -/generator/Gemfile.lock -/Landscape.jpg -/Portrait.jpg -/sources diff --git a/third_party/exif_orientation_examples/LICENSE b/third_party/exif_orientation_examples/LICENSE deleted file mode 100644 index 978ee2ae82..0000000000 --- a/third_party/exif_orientation_examples/LICENSE +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2010 Dave Perrett, http://recursive-design.com/ - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. \ No newline at end of file diff --git a/third_party/exif_orientation_examples/Landscape_1.jpg b/third_party/exif_orientation_examples/Landscape_1.jpg deleted file mode 100644 index fda188236a..0000000000 Binary files a/third_party/exif_orientation_examples/Landscape_1.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Landscape_2.jpg b/third_party/exif_orientation_examples/Landscape_2.jpg deleted file mode 100644 index d2605f81b9..0000000000 Binary files a/third_party/exif_orientation_examples/Landscape_2.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Landscape_3.jpg b/third_party/exif_orientation_examples/Landscape_3.jpg deleted file mode 100644 index f508052340..0000000000 Binary files a/third_party/exif_orientation_examples/Landscape_3.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Landscape_4.jpg b/third_party/exif_orientation_examples/Landscape_4.jpg deleted file mode 100644 index d73dee8fd7..0000000000 Binary files a/third_party/exif_orientation_examples/Landscape_4.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Landscape_5.jpg b/third_party/exif_orientation_examples/Landscape_5.jpg deleted file mode 100644 index 975d858838..0000000000 Binary files a/third_party/exif_orientation_examples/Landscape_5.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Landscape_6.jpg b/third_party/exif_orientation_examples/Landscape_6.jpg deleted file mode 100644 index b579b7f9ab..0000000000 Binary files a/third_party/exif_orientation_examples/Landscape_6.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Landscape_7.jpg b/third_party/exif_orientation_examples/Landscape_7.jpg deleted file mode 100644 index b1e919cfd9..0000000000 Binary files a/third_party/exif_orientation_examples/Landscape_7.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Landscape_8.jpg b/third_party/exif_orientation_examples/Landscape_8.jpg deleted file mode 100644 index c381db10e6..0000000000 Binary files a/third_party/exif_orientation_examples/Landscape_8.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Makefile b/third_party/exif_orientation_examples/Makefile deleted file mode 100644 index c5fc2e0e4c..0000000000 --- a/third_party/exif_orientation_examples/Makefile +++ /dev/null @@ -1,11 +0,0 @@ -all: portrait landscape - -portrait: - curl --location https://source.unsplash.com/random/1200x1600 --output ./Portrait.jpg - bash -c "cd generator && ./generate.rb ../Portrait.jpg" - rm -f ./Portrait.jpg - -landscape: - curl --location https://source.unsplash.com/random/1600x1200 --output ./Landscape.jpg - bash -c "cd generator && ./generate.rb ../Landscape.jpg" - rm -f ./Landscape.jpg diff --git a/third_party/exif_orientation_examples/Portrait_1.jpg b/third_party/exif_orientation_examples/Portrait_1.jpg deleted file mode 100644 index dcb57c537f..0000000000 Binary files a/third_party/exif_orientation_examples/Portrait_1.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Portrait_2.jpg b/third_party/exif_orientation_examples/Portrait_2.jpg deleted file mode 100644 index 8c3adf7afb..0000000000 Binary files a/third_party/exif_orientation_examples/Portrait_2.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Portrait_3.jpg b/third_party/exif_orientation_examples/Portrait_3.jpg deleted file mode 100644 index 5a5544f233..0000000000 Binary files a/third_party/exif_orientation_examples/Portrait_3.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Portrait_4.jpg b/third_party/exif_orientation_examples/Portrait_4.jpg deleted file mode 100644 index 9eb2a6a1e6..0000000000 Binary files a/third_party/exif_orientation_examples/Portrait_4.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Portrait_5.jpg b/third_party/exif_orientation_examples/Portrait_5.jpg deleted file mode 100644 index 905169aa75..0000000000 Binary files a/third_party/exif_orientation_examples/Portrait_5.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Portrait_6.jpg b/third_party/exif_orientation_examples/Portrait_6.jpg deleted file mode 100644 index 8fc576e067..0000000000 Binary files a/third_party/exif_orientation_examples/Portrait_6.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Portrait_7.jpg b/third_party/exif_orientation_examples/Portrait_7.jpg deleted file mode 100644 index cfa04d66e0..0000000000 Binary files a/third_party/exif_orientation_examples/Portrait_7.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/Portrait_8.jpg b/third_party/exif_orientation_examples/Portrait_8.jpg deleted file mode 100644 index b2a50d6eb6..0000000000 Binary files a/third_party/exif_orientation_examples/Portrait_8.jpg and /dev/null differ diff --git a/third_party/exif_orientation_examples/README.markdown b/third_party/exif_orientation_examples/README.markdown deleted file mode 100644 index 61c0b85955..0000000000 --- a/third_party/exif_orientation_examples/README.markdown +++ /dev/null @@ -1,82 +0,0 @@ -EXIF Orientation-flag example images -==================================== - -Example images using each of the EXIF orientation flags (1-to-8), in both landscape and portrait orientations. - -[See here](http://www.daveperrett.com/articles/2012/07/28/exif-orientation-handling-is-a-ghetto/) for more information. - - -Generating your own images --------------------------- - -If you would like to generate test images based on your own photos, you can use the `generate.rb` script included in the `generator` folder. - -The instructions below assume you are running on OSX - if not, you will need to install the Ghostscript fonts (`brew install gs`) some other way. - -To install the dependencies: - -``` -> brew install gs -> cd generator -> gem install bundler -> bundle install -``` - -To generate test images: - -``` -> cd generator -> ./generate path/to/image.jpg -``` - -This will create images `image_1.jpg` through to `image_8.jpg`. - - -Re-generating sample images ---------------------------- - -Simply run `make` to regenerate the included sample images. This will download random portrait and landscape orientation images from [unsplash.com](https://unsplash.com/) and generate sample images for each of them. - -Generating these images depends on having the generator dependencies installed - see the *Generating your own images* section for instructions on installing dependencies. - - -Credits -------- - -* The sample landscape image is by [Pierre Bouillot](https://unsplash.com/photos/v15iOM6pWgI). -* The sample portrait image is by [John Salvino](https://unsplash.com/photos/1PPpwrTNkJI). - - -Change history --------------- - -* **Version 2.0.0 (2017-08-05)** : Add a script to generate example images from the command line. -* **Version 1.0.2 (2017-03-06)** : Remove Apple Copyrighted ICC profile from orientations 2-8 (thanks @mans0954!). -* **Version 1.0.1 (2013-03-10)** : Add MIT license and some contact details. -* **Version 1.0.0 (2012-07-28)** : 1.0 release. - - -Contributing ------------- - -Once you've made your commits: - -1. [Fork](http://help.github.com/fork-a-repo/) exif-orientation-examples -2. Create a topic branch - `git checkout -b my_branch` -3. Push to your branch - `git push origin my_branch` -4. Create a [Pull Request](http://help.github.com/pull-requests/) from your branch -5. That's it! - - -Author ------- - -Dave Perrett :: hello@daveperrett.com :: [@daveperrett](http://twitter.com/daveperrett) - - -Copyright ---------- - -These images are licensed under the [MIT License](http://opensource.org/licenses/MIT). - -Copyright (c) 2010 Dave Perrett. See [License](https://github.com/recurser/exif-orientation-examples/blob/master/LICENSE) for details. diff --git a/third_party/exif_orientation_examples/README.third_party b/third_party/exif_orientation_examples/README.third_party deleted file mode 100644 index e27da75624..0000000000 --- a/third_party/exif_orientation_examples/README.third_party +++ /dev/null @@ -1,10 +0,0 @@ -URL: https://github.com/recurser/exif-orientation-examples/tree/d06cd11258b98b24b3cd8d391ee5bf4961a80853 -Version: d06cd11258b98b24b3cd8d391ee5bf4961a80853 -License: MIT -License File: LICENSE - -Description: -Sample images with all of the supported exif orientations. - -Local Modifications: -None diff --git a/third_party/exif_orientation_examples/VERSION b/third_party/exif_orientation_examples/VERSION deleted file mode 100644 index 227cea2156..0000000000 --- a/third_party/exif_orientation_examples/VERSION +++ /dev/null @@ -1 +0,0 @@ -2.0.0 diff --git a/third_party/exif_orientation_examples/generator/Gemfile b/third_party/exif_orientation_examples/generator/Gemfile deleted file mode 100644 index 65e8f880b0..0000000000 --- a/third_party/exif_orientation_examples/generator/Gemfile +++ /dev/null @@ -1,4 +0,0 @@ -source 'https://rubygems.org' -ruby '2.4.1' - -gem 'rmagick' diff --git a/third_party/exif_orientation_examples/generator/generate.rb b/third_party/exif_orientation_examples/generator/generate.rb deleted file mode 100755 index 4df576670e..0000000000 --- a/third_party/exif_orientation_examples/generator/generate.rb +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env ruby - -# Make sure to 'brew install gs' before running this. - -require 'RMagick' -require 'tempfile' - -if ARGV.length != 1 - abort "Usage: #{$PROGRAM_NAME} /path/to/image" -end - -# Make sure the file exists. -source = ARGV[0] -abort "Error: File '#{source}' not found" unless File.exist?(source) && File.file?(source) - -# Copy it to the temp directory. -path = Tempfile.new('to-convert').path -FileUtils.cp source, path - -# Make sure it's an image. -image = begin - Magick::Image::read(path).first -rescue Magick::ImageMagickError - abort "Error: File '#{source}' does not appear to be an image." -end - -# Make sure exiftool and convert are available. -abort 'Error: The exiftool command does not appear to be available' if `which exiftool` == '' -abort 'Error: The convert command does not appear to be available' if `which convert` == '' -abort 'Error: the input file must be a JPEG' unless image.format == 'JPEG' - -# Decide where we'll put the output. -dest_folder = File.dirname(source) -dest_file_base = File.basename(source, '.*') -dest_extention = File.extname(source) - -# Strip all exif data. -`exiftool -all= #{path}` - -# Strip color profile info. -FileUtils.cp path, "#{path}.convert" -`convert #{path}.convert +profile "*" #{path}` -FileUtils.rm_f "#{path}.convert" - -# Decide on a suitable font size. -dimension = [image.rows, image.columns].max -font_size = dimension / 20 - -# Add top / right / bottom / left text. -text = Magick::Draw.new -text.font_family = 'helvetica' -text.pointsize = font_size -text.fill = 'white' -text.stroke = 'black' -text.stroke_width = 1 -edge_padding = font_size / 4 - -text.annotate(image, 0, 0, 0, edge_padding, 'top') do - self.gravity = Magick::NorthGravity -end - -text.annotate(image, 0, 0, 0, edge_padding, 'bottom') do - self.gravity = Magick::SouthGravity -end - -text.annotate(image, 0, 0, edge_padding, 0, 'right') do - self.gravity = Magick::EastGravity -end - -text.annotate(image, 0, 0, edge_padding, 0, 'left') do - self.gravity = Magick::WestGravity -end - -transformations = [ - { - exif_tag: 1, - rotation_degrees: 0, - flop: false, - }, - { - exif_tag: 2, - rotation_degrees: 0, - flop: true, - }, - - { - exif_tag: 3, - rotation_degrees: 180, - flop: false, - }, - { - exif_tag: 4, - rotation_degrees: 180, - flop: true, - }, - { - exif_tag: 5, - rotation_degrees: -90, - flop: true, - }, - { - exif_tag: 6, - rotation_degrees: -90, - flop: false, - }, - { - exif_tag: 7, - rotation_degrees: 90, - flop: true, - }, - { - exif_tag: 8, - rotation_degrees: 90, - flop: false, - }, -] - -transformations.each do |t| - tmp_image = image.dup - - # Add centered text displaying the orientation tag number. - text.annotate(tmp_image, 0, 0, 0, 0, t[:exif_tag].to_s) do - self.gravity = Magick::CenterGravity - text.pointsize = font_size * 2 - end - - # Rotate and transform the image. - tmp_image.flop! if t[:flop] - tmp_image.rotate! t[:rotation_degrees] if t[:rotation_degrees] != 0 - out_path = File.join(dest_folder, "#{dest_file_base}_#{t[:exif_tag]}#{dest_extention}") - tmp_image.write(out_path) - - # Set the EXIF Orientation tag. - `exiftool -overwrite_original -orientation=#{t[:exif_tag]} -n #{out_path}` -end diff --git a/third_party/gif_decoder/README.third_party b/third_party/gif_decoder/THIRD_PARTY.md similarity index 98% rename from third_party/gif_decoder/README.third_party rename to third_party/gif_decoder/THIRD_PARTY.md index e872bcd166..aa1440c55e 100644 --- a/third_party/gif_decoder/README.third_party +++ b/third_party/gif_decoder/THIRD_PARTY.md @@ -10,8 +10,8 @@ every image frame. Images are instead decoded on-the-fly, and only the minimum data to create the next frame in the sequence is kept. The implementation has also been adapted to reduce memory allocations in the decoding process to reduce time to render each frame. - -Adapted from: + +Adapted from: http://show.docjava.com/book/cgij/exportToHTML/ip/gif/stills/GifDecoder.java.html Local Modifications: diff --git a/third_party/gif_decoder/build.gradle b/third_party/gif_decoder/build.gradle deleted file mode 100644 index 4c734b2514..0000000000 --- a/third_party/gif_decoder/build.gradle +++ /dev/null @@ -1,23 +0,0 @@ -apply plugin: 'com.android.library' - -dependencies { - implementation "androidx.annotation:annotation:${ANDROID_X_ANNOTATION_VERSION}" - - testImplementation project(':testutil') - testImplementation "androidx.annotation:annotation:${ANDROID_X_ANNOTATION_VERSION}" - testImplementation "com.google.truth:truth:${TRUTH_VERSION}" - testImplementation "junit:junit:${JUNIT_VERSION}" - testImplementation "org.mockito:mockito-core:${MOCKITO_VERSION}" - testImplementation "org.robolectric:robolectric:${ROBOLECTRIC_VERSION}" -} - -android { - compileSdkVersion COMPILE_SDK_VERSION as int - - defaultConfig { - minSdkVersion MIN_SDK_VERSION as int - targetSdkVersion TARGET_SDK_VERSION as int - } -} - -apply from: "${rootProject.projectDir}/scripts/upload.gradle" diff --git a/third_party/gif_decoder/build.gradle.kts b/third_party/gif_decoder/build.gradle.kts new file mode 100644 index 0000000000..31b5d1ed5d --- /dev/null +++ b/third_party/gif_decoder/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + id("com.android.library") +} + +android { + namespace = "com.bumptech.glide.gifdecoder" + compileSdk = libs.versions.compile.sdk.version.get().toInt() + + defaultConfig { + minSdk = libs.versions.min.sdk.version.get().toInt() + } +} + +dependencies { + implementation(libs.androidx.annotation) + + testImplementation(project(":testutil")) + testImplementation(libs.androidx.annotation) + testImplementation(libs.truth) + testImplementation(libs.junit) + testImplementation(libs.mockito.core) + testImplementation(libs.robolectric) +} + +apply(from = "${rootProject.projectDir}/scripts/upload.gradle.kts") \ No newline at end of file diff --git a/third_party/gif_decoder/src/main/AndroidManifest.xml b/third_party/gif_decoder/src/main/AndroidManifest.xml deleted file mode 100644 index 9bdfa020a6..0000000000 --- a/third_party/gif_decoder/src/main/AndroidManifest.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/third_party/gif_decoder/src/main/java/com/bumptech/glide/gifdecoder/GifFrame.java b/third_party/gif_decoder/src/main/java/com/bumptech/glide/gifdecoder/GifFrame.java index 2d4794c85d..fcb24eb17f 100644 --- a/third_party/gif_decoder/src/main/java/com/bumptech/glide/gifdecoder/GifFrame.java +++ b/third_party/gif_decoder/src/main/java/com/bumptech/glide/gifdecoder/GifFrame.java @@ -1,7 +1,7 @@ package com.bumptech.glide.gifdecoder; -import androidx.annotation.IntDef; import androidx.annotation.ColorInt; +import androidx.annotation.IntDef; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; diff --git a/third_party/gif_decoder/src/main/java/com/bumptech/glide/gifdecoder/GifHeaderParser.java b/third_party/gif_decoder/src/main/java/com/bumptech/glide/gifdecoder/GifHeaderParser.java index 0a38c2d21d..a26d527f1d 100644 --- a/third_party/gif_decoder/src/main/java/com/bumptech/glide/gifdecoder/GifHeaderParser.java +++ b/third_party/gif_decoder/src/main/java/com/bumptech/glide/gifdecoder/GifHeaderParser.java @@ -4,9 +4,9 @@ import static com.bumptech.glide.gifdecoder.GifFrame.DISPOSAL_NONE; import static com.bumptech.glide.gifdecoder.GifFrame.DISPOSAL_UNSPECIFIED; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import android.util.Log; import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; import java.nio.ByteOrder; @@ -355,7 +355,7 @@ private void readNetscapeExt() { int b2 = ((int) block[2]) & MASK_INT_LOWEST_BYTE; header.loopCount = (b2 << 8) | b1; } - } while ((blockSize > 0) && !err()); + } while (blockSize > 0 && !err()); } diff --git a/third_party/gif_decoder/src/main/java/com/bumptech/glide/gifdecoder/StandardGifDecoder.java b/third_party/gif_decoder/src/main/java/com/bumptech/glide/gifdecoder/StandardGifDecoder.java index 24690bb6fd..0ec1bbf880 100644 --- a/third_party/gif_decoder/src/main/java/com/bumptech/glide/gifdecoder/StandardGifDecoder.java +++ b/third_party/gif_decoder/src/main/java/com/bumptech/glide/gifdecoder/StandardGifDecoder.java @@ -30,10 +30,10 @@ import android.graphics.Bitmap; import android.graphics.Bitmap.Config; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import android.util.Log; import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -59,7 +59,7 @@ * * @see GIF 89a Specification */ -public class StandardGifDecoder implements GifDecoder { +public final class StandardGifDecoder implements GifDecoder { private static final String TAG = StandardGifDecoder.class.getSimpleName(); /** Maximum pixel stack size for decoding LZW compressed data. */ @@ -168,7 +168,7 @@ public void advance() { @Override public int getDelay(int n) { int delay = -1; - if ((n >= 0) && (n < header.frameCount)) { + if (n >= 0 && n < header.frameCount) { delay = header.frames.get(n).delay; } return delay; @@ -499,6 +499,7 @@ private Bitmap setPixels(GifFrame currentFrame, GifFrame previousFrame) { return result; } + @SuppressWarnings("checkstyle:UnnecessaryParentheses") // Readability private void copyIntoScratchFast(GifFrame currentFrame) { int[] dest = mainScratch; int downsampledIH = currentFrame.ih; @@ -807,7 +808,7 @@ private void decodeBitmapData(GifFrame frame) { prefix[available] = (short) oldCode; suffix[available] = (byte) first; ++available; - if (((available & codeMask) == 0) && (available < MAX_STACK_SIZE)) { + if ((available & codeMask) == 0 && available < MAX_STACK_SIZE) { ++codeSize; codeMask += available; } diff --git a/third_party/gif_decoder/src/test/java/com/bumptech/glide/gifdecoder/GifDecoderTest.java b/third_party/gif_decoder/src/test/java/com/bumptech/glide/gifdecoder/GifDecoderTest.java index 5f16febb54..4327175d02 100644 --- a/third_party/gif_decoder/src/test/java/com/bumptech/glide/gifdecoder/GifDecoderTest.java +++ b/third_party/gif_decoder/src/test/java/com/bumptech/glide/gifdecoder/GifDecoderTest.java @@ -3,26 +3,20 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; -import static org.robolectric.Shadows.shadowOf; import android.graphics.Bitmap; import androidx.annotation.NonNull; import com.bumptech.glide.testutil.TestUtil; import java.io.IOException; -import java.util.Arrays; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; -import org.robolectric.Shadows; import org.robolectric.annotation.Config; -import org.robolectric.annotation.Implementation; -import org.robolectric.annotation.Implements; -import org.robolectric.shadows.ShadowBitmap; /** Tests for {@link com.bumptech.glide.gifdecoder.GifDecoder}. */ @RunWith(RobolectricTestRunner.class) -@Config(sdk = 18) +@Config(sdk = Config.OLDEST_SDK) public class GifDecoderTest { private MockProvider provider; @@ -141,7 +135,6 @@ public void testSettingDataResetsFramePointer() { } @Test - @Config(shadows = {CustomShadowBitmap.class}) public void testFirstFrameMustClearBeforeDrawingWhenLastFrameIsDisposalBackground() throws IOException { byte[] data = TestUtil.resourceToBytes(getClass(), "transparent_disposal_background.gif"); @@ -156,12 +149,10 @@ public void testFirstFrameMustClearBeforeDrawingWhenLastFrameIsDisposalBackgroun decoder.getNextFrame(); decoder.advance(); Bitmap firstFrameTwice = decoder.getNextFrame(); - assertTrue(Arrays.equals((((CustomShadowBitmap) shadowOf(firstFrame))).getPixels(), - (((CustomShadowBitmap) shadowOf(firstFrameTwice))).getPixels())); + assertTrue(firstFrame.sameAs(firstFrameTwice)); } @Test - @Config(shadows = {CustomShadowBitmap.class}) public void testFirstFrameMustClearBeforeDrawingWhenLastFrameIsDisposalNone() throws IOException { byte[] data = TestUtil.resourceToBytes(getClass(), "transparent_disposal_none.gif"); GifHeaderParser headerParser = new GifHeaderParser(); @@ -175,28 +166,7 @@ public void testFirstFrameMustClearBeforeDrawingWhenLastFrameIsDisposalNone() th decoder.getNextFrame(); decoder.advance(); Bitmap firstFrameTwice = decoder.getNextFrame(); - assertTrue(Arrays.equals((((CustomShadowBitmap) shadowOf(firstFrame))).getPixels(), - (((CustomShadowBitmap) shadowOf(firstFrameTwice))).getPixels())); - } - - /** - * Preserve generated bitmap data for checking. - */ - @Implements(Bitmap.class) - public static class CustomShadowBitmap extends ShadowBitmap { - - private int[] pixels; - - @Implementation - public void setPixels(int[] pixels, int offset, int stride, - int x, int y, int width, int height) { - this.pixels = new int[pixels.length]; - System.arraycopy(pixels, 0, this.pixels, 0, this.pixels.length); - } - - public int[] getPixels() { - return pixels; - } + assertTrue(firstFrame.sameAs(firstFrameTwice)); } private static class MockProvider implements GifDecoder.BitmapProvider { @@ -204,9 +174,7 @@ private static class MockProvider implements GifDecoder.BitmapProvider { @NonNull @Override public Bitmap obtain(int width, int height, Bitmap.Config config) { - Bitmap result = Bitmap.createBitmap(width, height, config); - Shadows.shadowOf(result).setMutable(true); - return result; + return Bitmap.createBitmap(width, height, config); } @Override @@ -235,6 +203,5 @@ public int[] obtainIntArray(int size) { public void release(@NonNull int[] array) { // Do Nothing } - } } diff --git a/third_party/gif_encoder/README.third_party b/third_party/gif_encoder/THIRD_PARTY.md similarity index 100% rename from third_party/gif_encoder/README.third_party rename to third_party/gif_encoder/THIRD_PARTY.md diff --git a/third_party/gif_encoder/src/main/AndroidManifest.xml b/third_party/gif_encoder/src/main/AndroidManifest.xml deleted file mode 100644 index 0b4b09c397..0000000000 --- a/third_party/gif_encoder/src/main/AndroidManifest.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/third_party/gif_encoder/src/main/java/com/bumptech/glide/gifencoder/AnimatedGifEncoder.java b/third_party/gif_encoder/src/main/java/com/bumptech/glide/gifencoder/AnimatedGifEncoder.java index e4e381c9e3..ebf3b27727 100644 --- a/third_party/gif_encoder/src/main/java/com/bumptech/glide/gifencoder/AnimatedGifEncoder.java +++ b/third_party/gif_encoder/src/main/java/com/bumptech/glide/gifencoder/AnimatedGifEncoder.java @@ -4,9 +4,9 @@ import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import android.util.Log; import java.io.BufferedOutputStream; import java.io.FileOutputStream; import java.io.IOException;