Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Unverified Commit 6c6f8f6f authored by David Luhmer's avatar David Luhmer Committed by GitHub
Browse files

Merge pull request #52 from nextcloud/unit-testing

implement InterceptorPattern for NetworkRequest
parents 4ea9ab33 72fb3956
Loading
Loading
Loading
Loading
+3 −1
Original line number Diff line number Diff line
@@ -51,6 +51,8 @@ android {
}

dependencies {
    implementation 'androidx.legacy:legacy-support-v4:1.0.0'
    implementation "androidx.appcompat:appcompat:1.0.2"
    implementation 'androidx.annotation:annotation:1.0.2'
    implementation 'androidx.core:core:1.0.1'
    implementation 'androidx.fragment:fragment:1.0.0'
@@ -70,5 +72,5 @@ dependencies {
    // Required for local unit tests (JUnit 4 framework)
    testImplementation 'junit:junit:4.12'
    // required if you want to use Mockito for unit tests
    testImplementation 'org.mockito:mockito-core:2.25.1'
    testImplementation 'org.mockito:mockito-core:2.26.0'
}
+25 −5
Original line number Diff line number Diff line
@@ -61,17 +61,24 @@ public class AccountImporter {
    public static final int CHOOSE_ACCOUNT_SSO = 4242;
    public static final int REQUEST_AUTH_TOKEN_SSO = 4243;

    private static SharedPreferences SHARED_PREFERENCES;

    public static boolean accountsToImportAvailable(Context context) {
        return findAccounts(context).size() > 0;
    }

    public static void pickNewAccount(Activity activity) throws NextcloudFilesAppNotInstalledException {
        if (appInstalledOrNot(activity, "com.nextcloud.client")) {
            Intent intent = AccountManager.newChooseAccountIntent(null, null, new String[]{"nextcloud"},
                    true, null, null, null, null);
            activity.startActivityForResult(intent, CHOOSE_ACCOUNT_SSO);
        } else {
            throw new NextcloudFilesAppNotInstalledException();
        }
    }

    public static void pickNewAccount(Fragment fragment) throws NextcloudFilesAppNotInstalledException {
        if (appInstalledOrNot(fragment.getContext(), "com.nextcloud.client")) {

            // Clear all tokens first to prevent some caching issues..
            //clearAllAuthTokens(fragment.getContext());

            Intent intent = AccountManager.newChooseAccountIntent(null, null, new String[]{"nextcloud"},
                    true, null, null, null, null);
            fragment.startActivityForResult(intent, CHOOSE_ACCOUNT_SSO);
@@ -269,10 +276,23 @@ public class AccountImporter {


    public static SharedPreferences getSharedPreferences(Context context) {
        if(SHARED_PREFERENCES != null) {
            return SHARED_PREFERENCES;
        } else {
            return context.getSharedPreferences(SSO_SHARED_PREFERENCE, Context.MODE_PRIVATE);
        }
    }

    protected static String getPrefKeyForAccount(String accountName) {
        return PREF_ACCOUNT_STRING + accountName;
    }


    /**
     * Allows developers to set the shared preferences that the account information should be stored in.
     * This is helpful when writing unit tests
     */
    public static void setSharedPreferences(SharedPreferences sharedPreferences) {
        AccountImporter.SHARED_PREFERENCES = sharedPreferences;
    }
}
+220 −0
Original line number Diff line number Diff line
package com.nextcloud.android.sso.api;

import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.IBinder;
import android.os.Looper;
import android.os.NetworkOnMainThreadException;
import android.os.ParcelFileDescriptor;
import android.os.RemoteException;
import android.util.Log;

import com.nextcloud.android.sso.aidl.IInputStreamService;
import com.nextcloud.android.sso.aidl.IThreadListener;
import com.nextcloud.android.sso.aidl.NextcloudRequest;
import com.nextcloud.android.sso.aidl.ParcelFileDescriptorUtil;
import com.nextcloud.android.sso.exceptions.NextcloudApiNotRespondingException;
import com.nextcloud.android.sso.model.SingleSignOnAccount;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.concurrent.atomic.AtomicBoolean;

import static com.nextcloud.android.sso.exceptions.SSOException.parseNextcloudCustomException;

public class AidlNetworkRequest extends NetworkRequest {
    private static final String TAG = AidlNetworkRequest.class.getCanonicalName();

    private IInputStreamService mService = null;
    private final AtomicBoolean mBound = new AtomicBoolean(false); // Flag indicating whether we have called bind on the service

    AidlNetworkRequest(Context context, SingleSignOnAccount account, NextcloudAPI.ApiConnectedListener callback) {
        super(context, account, callback);
    }


    /**
     * Class for interacting with the main interface of the service.
     */
    private ServiceConnection mConnection = new ServiceConnection() {
        public void onServiceConnected(ComponentName className, IBinder service) {
            Log.v(TAG, "Nextcloud Single sign-on: onServiceConnected [" + Thread.currentThread().getName() + "]");

            mService = IInputStreamService.Stub.asInterface(service);
            mBound.set(true);
            synchronized (mBound) {
                mBound.notifyAll();
            }
            mCallback.onConnected();
        }

        public void onServiceDisconnected(ComponentName className) {
            Log.e(TAG, "Nextcloud Single sign-on: ServiceDisconnected");
            // This is called when the connection with the service has been
            // unexpectedly disconnected -- that is, its process crashed.
            mService = null;
            mBound.set(false);

            if (!mDestroyed) {
                connectApiWithBackoff();
            }
        }
    };

    public void connect() {
        super.connect();

        // Disconnect if connected
        if (mBound.get()) {
            stop();
        }

        try {
            Intent intentService = new Intent();
            intentService.setComponent(new ComponentName("com.nextcloud.client",
                    "com.owncloud.android.services.AccountManagerService"));
            if (!mContext.bindService(intentService, mConnection, Context.BIND_AUTO_CREATE)) {
                Log.d(TAG, "Binding to AccountManagerService returned false");
                throw new IllegalStateException("Binding to AccountManagerService returned false");
            }
        } catch (SecurityException e) {
            Log.e(TAG, "can't bind to AccountManagerService, check permission in Manifest");
            mCallback.onError(e);
        }
    }

    public void stop() {
        super.stop();

        // Unbind from the service
        if (mBound.get()) {
            if (mContext != null) {
                mContext.unbindService(mConnection);
            } else {
                Log.e(TAG, "Context was null, cannot unbind nextcloud single sign-on service connection!");
            }
            mBound.set(false);
            mContext = null;
        }
    }

    private void waitForApi() throws NextcloudApiNotRespondingException {
        synchronized (mBound) {
            // If service is not bound yet.. wait
            if(!mBound.get()) {
                Log.v(TAG, "[waitForApi] - api not ready yet.. waiting [" + Thread.currentThread().getName() +  "]");
                try {
                    mBound.wait(10000); // wait up to 10 seconds

                    // If api is still not bound after 10 seconds.. throw an exception
                    if(!mBound.get()) {
                        throw new NextcloudApiNotRespondingException();
                    }
                } catch (InterruptedException ex) {
                    Log.e(TAG, "WaitForAPI failed", ex);
                }
            }
        }
    }

    /**
     * The InputStreams needs to be closed after reading from it
     *
     * @param request {@link NextcloudRequest} request to be executed on server via Files app
     * @param requestBodyInputStream inputstream to be sent to the server
     * @return InputStream answer from server as InputStream
     * @throws Exception or SSOException
     */
    public InputStream performNetworkRequest(NextcloudRequest request, InputStream requestBodyInputStream) throws Exception {
        InputStream os = null;
        Exception exception;
        try {
            ParcelFileDescriptor output = performAidlNetworkRequest(request, requestBodyInputStream);
            os = new ParcelFileDescriptor.AutoCloseInputStream(output);
            exception = deserializeObject(os);
        } catch (ClassNotFoundException e) {
            //e.printStackTrace();
            exception = e;
        }

        // Handle Remote Exceptions
        if (exception != null) {
            if (exception.getMessage() != null) {
                exception = parseNextcloudCustomException(exception);
            }
            throw exception;
        }
        return os;
    }

    /**
     * DO NOT CALL THIS METHOD DIRECTLY - use @link(performNetworkRequest) instead
     *
     * @param request
     * @return
     * @throws IOException
     */
    private ParcelFileDescriptor performAidlNetworkRequest(NextcloudRequest request, InputStream requestBodyInputStream)
        throws IOException, RemoteException, NextcloudApiNotRespondingException {

        // Check if we are on the main thread
        if(Looper.myLooper() == Looper.getMainLooper()) {
            throw new NetworkOnMainThreadException();
        }

        // Wait for api to be initialized
        waitForApi();

        // Log.d(TAG, request.url);
        request.setAccountName(getAccountName());
        request.setToken(getAccountToken());

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        oos.writeObject(request);
        oos.close();
        baos.close();
        InputStream is = new ByteArrayInputStream(baos.toByteArray());

        ParcelFileDescriptor input = ParcelFileDescriptorUtil.pipeFrom(is,
                new IThreadListener() {
                    @Override
                    public void onThreadFinished(Thread thread) {
                        Log.d(TAG, "copy data from service finished");
                    }
                });

        ParcelFileDescriptor requestBodyParcelFileDescriptor = null;
        if(requestBodyInputStream != null) {
            requestBodyParcelFileDescriptor = ParcelFileDescriptorUtil.pipeFrom(requestBodyInputStream,
                    new IThreadListener() {
                        @Override
                        public void onThreadFinished(Thread thread) {
                            Log.d(TAG, "copy data from service finished");
                        }
                    });
        }

        ParcelFileDescriptor output;
        if(requestBodyParcelFileDescriptor != null) {
            output = mService.performNextcloudRequestAndBodyStream(input, requestBodyParcelFileDescriptor);
        } else {
            output = mService.performNextcloudRequest(input);
        }

        return output;
    }


    private static <T> T deserializeObject(InputStream is) throws IOException, ClassNotFoundException {
        ObjectInputStream ois = new ObjectInputStream(is);
        T result = (T) ois.readObject();
        return result;
    }
}
+63 −0
Original line number Diff line number Diff line
package com.nextcloud.android.sso.api;

import android.content.Context;
import android.os.Looper;
import android.util.Log;

import com.nextcloud.android.sso.aidl.NextcloudRequest;
import com.nextcloud.android.sso.helper.ExponentialBackoff;
import com.nextcloud.android.sso.model.SingleSignOnAccount;

import java.io.InputStream;

public abstract class NetworkRequest {

    private static final String TAG = NetworkRequest.class.getCanonicalName();

    private SingleSignOnAccount mAccount;
    protected Context mContext;
    protected NextcloudAPI.ApiConnectedListener mCallback;
    protected boolean mDestroyed = false; // Flag indicating if API is destroyed


    protected NetworkRequest(Context context, SingleSignOnAccount account, NextcloudAPI.ApiConnectedListener callback) {
        this.mContext = context;
        this.mAccount = account;
        this.mCallback = callback;
    }


    protected void connect() {
        Log.v(TAG, "Nextcloud Single sign-on connect() called [" + Thread.currentThread().getName() + "]");
        if (mDestroyed) {
            throw new IllegalStateException("API already destroyed! You cannot reuse a stopped API instance");
        }
    }

    protected abstract InputStream performNetworkRequest(NextcloudRequest request, InputStream requestBodyInputStream) throws Exception;

    protected void connectApiWithBackoff() {
        new ExponentialBackoff(1000, 10000, 2, 5, Looper.getMainLooper(), new Runnable() {
            @Override
            public void run() {
            connect();
            }
        }).start();
    }

    protected void stop() {
        mCallback = null;
        mAccount = null;
        mDestroyed = true;
    }


    protected String getAccountName() {
        return mAccount.name;
    }

    protected String getAccountToken() {
        return mAccount.token;
    }

}
+21 −227
Original line number Diff line number Diff line
@@ -19,43 +19,24 @@

package com.nextcloud.android.sso.api;

import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.IBinder;
import android.os.Looper;
import android.os.NetworkOnMainThreadException;
import android.os.ParcelFileDescriptor;
import android.os.RemoteException;
import android.util.Log;

import com.google.gson.Gson;
import com.nextcloud.android.sso.aidl.IInputStreamService;
import com.nextcloud.android.sso.aidl.NextcloudRequest;
import com.nextcloud.android.sso.aidl.ParcelFileDescriptorUtil;
import com.nextcloud.android.sso.exceptions.NextcloudApiNotRespondingException;
import com.nextcloud.android.sso.helper.ExponentialBackoff;
import com.nextcloud.android.sso.model.SingleSignOnAccount;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Reader;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.lang.reflect.Type;
import java.util.concurrent.atomic.AtomicBoolean;

import io.reactivex.Observable;
import io.reactivex.annotations.NonNull;

import static com.nextcloud.android.sso.exceptions.SSOException.parseNextcloudCustomException;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@@ -63,150 +44,46 @@ public class NextcloudAPI {

    private static final String TAG = NextcloudAPI.class.getCanonicalName();

    private NetworkRequest networkRequest;
    private Gson gson;
    private IInputStreamService mService = null;
    private final AtomicBoolean mBound = new AtomicBoolean(false); // Flag indicating whether we have called bind on the service
    private boolean mDestroyed = false; // Flag indicating if API is destroyed
    private SingleSignOnAccount mAccount;
    private ApiConnectedListener mCallback;
    private Context mContext;


    @Documented
    @Target(METHOD)
    @Retention(RUNTIME)
    public @interface FollowRedirects{

    }
    public @interface FollowRedirects { }

    public interface ApiConnectedListener {
        void onConnected();
        void onError(Exception ex);
    }


    public NextcloudAPI(Context context, SingleSignOnAccount account, Gson gson, ApiConnectedListener callback) {
        this.mContext = context;
        this.mAccount = account;
        this(gson, new AidlNetworkRequest(context, account, callback));
    }

    public NextcloudAPI(Gson gson, NetworkRequest networkRequest) {
        this.gson = gson;
        this.mCallback = callback;
        this.networkRequest = networkRequest;

        new Thread()
        {
        new Thread() {
            @Override
            public void run() {
                Log.d(TAG, "run() called " + Thread.currentThread().getName());
                connectApiWithBackoff();
                NextcloudAPI.this.networkRequest.connectApiWithBackoff();
            }
        }.start();
        //connectApiWithBackoff();
    }

    private String getAccountName() {
        return mAccount.name;
    }

    private String getAccountToken() {
        return mAccount.token;
    }

    private void connectApiWithBackoff() {
        new ExponentialBackoff(1000, 10000, 2, 5, Looper.getMainLooper(), this::connect).start();
    }

    private void connect() {
        Log.v(TAG, "Nextcloud Single sign-on connect() called [" + Thread.currentThread().getName() + "]");
        if (mDestroyed) {
            throw new IllegalStateException("API already destroyed! You cannot reuse a stopped API instance");
        }

        // Disconnect if connected
        if (mBound.get()) {
            stop();
        }

        try {
            Intent intentService = new Intent();
            intentService.setComponent(new ComponentName("com.nextcloud.client",
                    "com.owncloud.android.services.AccountManagerService"));
            if (!mContext.bindService(intentService, mConnection, Context.BIND_AUTO_CREATE)) {
                Log.d(TAG, "Binding to AccountManagerService returned false");
                throw new IllegalStateException("Binding to AccountManagerService returned false");
            }
        } catch (SecurityException e) {
            Log.e(TAG, "can't bind to AccountManagerService, check permission in Manifest");
            mCallback.onError(e);
    }
    }


    public void stop() {
        gson = null;
        mDestroyed = true;
        mAccount = null;
        mCallback = null;

        // Unbind from the service
        if (mBound.get()) {
            if (mContext != null) {
                mContext.unbindService(mConnection);
            } else {
                Log.e(TAG, "Context was null, cannot unbind nextcloud single sign-on service connection!");
            }
            mBound.set(false);
            mContext = null;
        }
    }


    /**
     * Class for interacting with the main interface of the service.
     */
    private ServiceConnection mConnection = new ServiceConnection() {
        public void onServiceConnected(ComponentName className, IBinder service) {
            Log.v(TAG, "Nextcloud Single sign-on: onServiceConnected [" + Thread.currentThread().getName() + "]");

            mService = IInputStreamService.Stub.asInterface(service);
            mBound.set(true);
            synchronized (mBound) {
                mBound.notifyAll();
            }
            mCallback.onConnected();
        }

        public void onServiceDisconnected(ComponentName className) {
            Log.e(TAG, "Nextcloud Single sign-on: ServiceDisconnected");
            // This is called when the connection with the service has been
            // unexpectedly disconnected -- that is, its process crashed.
            mService = null;
            mBound.set(false);

            if (!mDestroyed) {
                connectApiWithBackoff();
            }
        }
    };

    private void waitForApi() throws NextcloudApiNotRespondingException {
        synchronized (mBound) {
            // If service is not bound yet.. wait
            if(!mBound.get()) {
                Log.v(TAG, "[waitForApi] - api not ready yet.. waiting [" + Thread.currentThread().getName() +  "]");
                try {
                    mBound.wait(10000); // wait up to 10 seconds

                    // If api is still not bound after 10 seconds.. throw an exception
                    if(!mBound.get()) {
                        throw new NextcloudApiNotRespondingException();
                    }
                } catch (InterruptedException ex) {
                    Log.e(TAG, "WaitForAPI failed", ex);
                }
            }
        }
        networkRequest.stop();
    }

    public <T> Observable<T> performRequestObservable(final Type type, final NextcloudRequest request) {
        return Observable.fromPublisher( s-> {
            try {
                    s.onNext((T) performRequest(type, request));
                s.onNext(performRequest(type, request));
                s.onComplete();
            } catch (Exception e) {
                s.onError(e);
@@ -227,17 +104,10 @@ public class NextcloudAPI {
                }
            }
        }

        return result;
    }


    public static <T> T deserializeObject(InputStream is) throws IOException, ClassNotFoundException {
        ObjectInputStream ois = new ObjectInputStream(is);
        T result = (T) ois.readObject();
        return result;
    }

     /**
     * The InputStreams needs to be closed after reading from it
     *
@@ -246,93 +116,17 @@ public class NextcloudAPI {
     * @throws Exception or SSOException
     */
    public InputStream performNetworkRequest(NextcloudRequest request) throws Exception {
        return performNetworkRequest(request, null);
    }

    /**
     * The InputStreams needs to be closed after reading from it
     *
     * @param request {@link NextcloudRequest} request to be executed on server via Files app
     * @param requestBodyInputStream inputstream to be sent to the server
     * @return InputStream answer from server as InputStream
     * @throws Exception or SSOException
     */
    public InputStream performNetworkRequest(NextcloudRequest request, InputStream requestBodyInputStream) throws Exception {
        InputStream os = null;
        Exception exception;
        try {
            ParcelFileDescriptor output = performAidlNetworkRequest(request, requestBodyInputStream);
            os = new ParcelFileDescriptor.AutoCloseInputStream(output);
            exception = deserializeObject(os);
        } catch (ClassNotFoundException e) {
            //e.printStackTrace();
            exception = e;
        }

        // Handle Remote Exceptions
        if (exception != null) {
            if (exception.getMessage() != null) {
                exception = parseNextcloudCustomException(exception);
            }
            throw exception;
        }
        return os;
    }


    /**
     * DO NOT CALL THIS METHOD DIRECTLY - use @link(performNetworkRequest) instead
     *
     * @param request
     * @return
     * @throws IOException
     */
    private ParcelFileDescriptor performAidlNetworkRequest(NextcloudRequest request, InputStream requestBodyInputStream)
            throws IOException, RemoteException, NextcloudApiNotRespondingException {

        // Check if we are on the main thread
        if(Looper.myLooper() == Looper.getMainLooper()) {
            throw new NetworkOnMainThreadException();
        }

        // Wait for api to be initialized
        waitForApi();

        // Log.d(TAG, request.url);
        request.setAccountName(getAccountName());
        request.setToken(getAccountToken());

        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
             ObjectOutputStream oos = new ObjectOutputStream(baos)
        ) {
            oos.writeObject(request);
            try (InputStream is = new ByteArrayInputStream(baos.toByteArray());
                 ParcelFileDescriptor input = ParcelFileDescriptorUtil.pipeFrom(is,
                         thread -> Log.d(TAG, "copy data from service finished"))) {
                ParcelFileDescriptor requestBodyParcelFileDescriptor = null;
                if (requestBodyInputStream != null) {
                    requestBodyParcelFileDescriptor = ParcelFileDescriptorUtil.pipeFrom(
                            requestBodyInputStream,
                            thread -> Log.d(TAG, "copy data from service finished"));
                }

                ParcelFileDescriptor output;
                if(requestBodyParcelFileDescriptor != null) {
                    output = mService.performNextcloudRequestAndBodyStream(input, requestBodyParcelFileDescriptor);
                } else {
                    output = mService.performNextcloudRequest(input);
                }
                return output;
            }
        }
        return networkRequest.performNetworkRequest(request, null);
    }


    /*
    public static <T> T deserializeObjectAndCloseStream(InputStream is) throws IOException, ClassNotFoundException {
        try (ObjectInputStream ois = new ObjectInputStream(is)) {
            return (T) ois.readObject();
        }
    }
    */

    protected Gson getGson() {
        return gson;
Loading