diff --git a/mastodon/build.gradle b/mastodon/build.gradle index cb5fe46d..a7fa6f03 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -12,6 +12,7 @@ android { targetSdk 31 versionCode 26 versionName "0.1" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -72,4 +73,9 @@ dependencies { appcenterPrivateBetaImplementation "com.microsoft.appcenter:appcenter-distribute:${appCenterSdkVersion}" appcenterPublicBetaImplementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}" appcenterPublicBetaImplementation "com.microsoft.appcenter:appcenter-distribute:${appCenterSdkVersion}" + + androidTestImplementation 'androidx.test:core:1.4.1-alpha05' + androidTestImplementation 'androidx.test.ext:junit:1.1.4-alpha05' + androidTestImplementation 'androidx.test:runner:1.5.0-alpha02' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0-alpha05' } \ No newline at end of file diff --git a/mastodon/src/androidTest/assets/IMG_1010.jpg b/mastodon/src/androidTest/assets/IMG_1010.jpg new file mode 100644 index 00000000..0fce96bd Binary files /dev/null and b/mastodon/src/androidTest/assets/IMG_1010.jpg differ diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/test/StoreScreenshotsGenerator.java b/mastodon/src/androidTest/java/org/joinmastodon/android/test/StoreScreenshotsGenerator.java new file mode 100644 index 00000000..d895e4cc --- /dev/null +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/test/StoreScreenshotsGenerator.java @@ -0,0 +1,252 @@ +package org.joinmastodon.android.test; + +import android.app.Instrumentation; +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.view.View; +import android.view.inputmethod.InputMethodManager; + +import org.hamcrest.CoreMatchers; +import org.hamcrest.Matcher; +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.MainActivity; +import org.joinmastodon.android.MastodonApp; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.instance.GetInstance; +import org.joinmastodon.android.api.requests.statuses.GetStatusByID; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.ComposeFragment; +import org.joinmastodon.android.fragments.ThreadFragment; +import org.joinmastodon.android.fragments.onboarding.InstanceRulesFragment; +import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.parceler.Parcels; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeoutException; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.espresso.PerformException; +import androidx.test.espresso.UiController; +import androidx.test.espresso.ViewAction; +import androidx.test.espresso.util.HumanReadables; +import androidx.test.espresso.util.TreeIterables; +import androidx.test.ext.junit.rules.ActivityScenarioRule; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.runner.screenshot.ScreenCapture; +import androidx.test.runner.screenshot.Screenshot; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import okio.BufferedSink; +import okio.Okio; +import okio.Sink; +import okio.Source; + +import static androidx.test.espresso.Espresso.*; +import static androidx.test.espresso.action.ViewActions.*; +import static androidx.test.espresso.assertion.ViewAssertions.*; +import static androidx.test.espresso.matcher.ViewMatchers.*; + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class StoreScreenshotsGenerator{ + private static final String PHOTO_FILE="IMG_1010.jpg"; + + @Rule + public ActivityScenarioRule activityScenarioRule=new ActivityScenarioRule<>(MainActivity.class); + + @Test + public void takeScreenshots() throws Exception{ + File photo=new File(MastodonApp.context.getCacheDir(), PHOTO_FILE); + try(Source source=Okio.source(InstrumentationRegistry.getInstrumentation().getContext().getAssets().open(PHOTO_FILE)); BufferedSink sink=Okio.buffer(Okio.sink(photo))){ + sink.writeAll(source); + sink.flush(); + } + + GlobalUserPreferences.theme=GlobalUserPreferences.ThemePreference.LIGHT; + Bundle args=InstrumentationRegistry.getArguments(); + InstrumentationRegistry.getInstrumentation().setInTouchMode(true); + + AccountSession session=AccountSessionManager.getInstance().getAccount(AccountSessionManager.getInstance().getLastActiveAccountID()); + MastodonApp.context.deleteDatabase(session.getID()+".db"); + + onView(isRoot()).perform(waitId(R.id.more, 5000)); + Thread.sleep(500); + takeScreenshot("HomeTimeline"); + + GlobalUserPreferences.theme=GlobalUserPreferences.ThemePreference.DARK; + activityScenarioRule.getScenario().recreate(); + + onView(isRoot()).perform(waitId(R.id.more, 5000)); + Thread.sleep(500); + takeScreenshot("HomeTimeline_Dark"); + + GlobalUserPreferences.theme=GlobalUserPreferences.ThemePreference.LIGHT; + activityScenarioRule.getScenario().recreate(); + + activityScenarioRule.getScenario().onActivity(activity->UiUtils.openProfileByID(activity, session.getID(), args.getString("profileAccountID"))); + Thread.sleep(500); + onView(isRoot()).perform(waitId(R.id.avatar_border, 5000)); // wait for profile to load + onView(isRoot()).perform(waitId(R.id.more, 5000)); // wait for timeline to load + Thread.sleep(500); + takeScreenshot("Profile"); + + Status[] _status={null}; + CyclicBarrier barrier=new CyclicBarrier(2); + new GetStatusByID(args.getString("threadPostID")) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Status result){ + _status[0]=result; + try{ + barrier.await(); + }catch(Exception ignore){} + } + + @Override + public void onError(ErrorResponse error){ + try{ + barrier.await(); + }catch(Exception ignore){} + } + }) + .exec(session.getID()); + barrier.await(); + Assert.assertNotNull(_status[0]); + + ThreadFragment[] _fragment={null}; + activityScenarioRule.getScenario().onActivity(activity->{ + activity.getSystemService(InputMethodManager.class).hideSoftInputFromWindow(activity.getWindow().getDecorView().getWindowToken(), 0); + Bundle threadArgs=new Bundle(); + threadArgs.putParcelable("status", Parcels.wrap(_status[0])); + threadArgs.putString("account", session.getID()); + threadArgs.putBoolean("_can_go_back", true); + ThreadFragment fragment=new ThreadFragment(); + fragment.setArguments(threadArgs); + activity.showFragment(fragment); + _fragment[0]=fragment; + }); + while(!_fragment[0].loaded){ + Thread.sleep(50); + } + Thread.sleep(300); + takeScreenshot("Thread"); + + Instance[] _instance={null}; + new GetInstance() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Instance result){ + _instance[0]=result; + try{ + barrier.await(); + }catch(Exception ignore){} + } + + @Override + public void onError(ErrorResponse error){ + try{ + barrier.await(); + }catch(Exception ignore){} + } + }) + .execNoAuth("mastodon.social"); + barrier.await(); + Assert.assertNotNull(_instance[0]); + + activityScenarioRule.getScenario().onActivity(activity->{ + Bundle rulesArgs=new Bundle(); + rulesArgs.putParcelable("instance", Parcels.wrap(_instance[0])); + InstanceRulesFragment fragment=new InstanceRulesFragment(); + fragment.setArguments(rulesArgs); + activity.showFragment(fragment); + }); + + Thread.sleep(500); + takeScreenshot("InstanceRules"); + + activityScenarioRule.getScenario().onActivity(activity->{ + activity.onBackPressed(); + Bundle composeArgs=new Bundle(); + composeArgs.putString("account", session.getID()); + ComposeFragment fragment=new ComposeFragment(); + fragment.setArguments(composeArgs); + activity.showFragment(fragment); + fragment.addFakeMediaAttachment(Uri.fromFile(photo), "Pantheon"); + }); + onView(withId(R.id.toot_text)).perform(typeText("This is a picture I took the last time I visited #Athens, Greece. What a beautiful place!")); + InstrumentationRegistry.getInstrumentation().setInTouchMode(true); + takeScreenshot("Compose"); + GlobalUserPreferences.theme=GlobalUserPreferences.ThemePreference.DARK; + activityScenarioRule.getScenario().recreate(); + Thread.sleep(500); + takeScreenshot("Compose_Dark"); + GlobalUserPreferences.theme=GlobalUserPreferences.ThemePreference.LIGHT; + activityScenarioRule.getScenario().recreate(); + } + + private void takeScreenshot(String name) throws IOException{ + Screenshot.capture().setName(name).setFormat(Bitmap.CompressFormat.PNG).process(); + } + + /** + * Perform action of waiting for a specific view id. + * @param viewId The id of the view to wait for. + * @param millis The timeout of until when to wait for. + */ + public static ViewAction waitId(final int viewId, final long millis) { + return new ViewAction() { + @Override + public Matcher getConstraints() { + return isRoot(); + } + + @Override + public String getDescription() { + return "wait for a specific view with id <" + viewId + "> during " + millis + " millis."; + } + + @Override + public void perform(final UiController uiController, final View view) { + uiController.loopMainThreadUntilIdle(); + final long startTime = System.currentTimeMillis(); + final long endTime = startTime + millis; + final Matcher viewMatcher=CoreMatchers.allOf(withId(viewId), withEffectiveVisibility(Visibility.VISIBLE)); + + do { + for (View child : TreeIterables.breadthFirstViewTraversal(view)) { + // found view with required ID + if (viewMatcher.matches(child)) { + return; + } + } + + uiController.loopMainThreadForAtLeast(50); + } + while (System.currentTimeMillis() < endTime); + + // timeout happens + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new TimeoutException()) + .build(); + } + }; + } + +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java index dd3d8e65..63fe2346 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -186,6 +186,9 @@ public class MastodonAPIController{ req.onError("Error parsing an API error", response.code()); } } + }catch(Exception x){ + Log.w(TAG, "onResponse: error processing response", x); + onFailure(call, (IOException) new IOException(x).fillInStackTrace()); } } }); diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusByID.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusByID.java new file mode 100644 index 00000000..39600fb6 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusByID.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests.statuses; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Status; + +public class GetStatusByID extends MastodonAPIRequest{ + public GetStatusByID(String id){ + super(HttpMethod.GET, "/statuses/"+id, Status.class); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java index acc4b55c..21bc3aa6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -91,6 +91,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; +import androidx.annotation.DrawableRes; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -796,6 +797,16 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis return thumb; } + public void addFakeMediaAttachment(Uri uri, String description){ + pollBtn.setEnabled(false); + DraftMediaAttachment draft=new DraftMediaAttachment(); + draft.uri=uri; + draft.description=description; + attachmentsView.addView(createMediaAttachmentView(draft)); + allAttachments.add(draft); + attachmentsView.setVisibility(View.VISIBLE); + } + private void uploadMediaAttachment(DraftMediaAttachment attachment){ if(uploadingAttachment!=null) throw new IllegalStateException("there is already an attachment being uploaded"); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java index b51d5ad0..7200f409 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java @@ -172,11 +172,13 @@ public class UiUtils{ } public static String getFileName(Uri uri){ - try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null)){ - cursor.moveToFirst(); - String name=cursor.getString(0); - if(name!=null) - return name; + if(uri.getScheme().equals("content")){ + try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null)){ + cursor.moveToFirst(); + String name=cursor.getString(0); + if(name!=null) + return name; + } } return uri.getLastPathSegment(); }