Loklak Wok Android is a peer harvester that posts collected tweets to the Loklak Server. Not only it is a peer harvester, but also provides users to post their tweets from the app. Images and location of the user can also be attached in the tweet. This blog explains
Adding Dependencies to the project
In app/build.gradle:
apply plugin: 'com.android.application' apply plugin: 'me.tatarka.retrolambda' android { ... packagingOptions { exclude 'META-INF/rxjava.properties' } } dependencies { ... compile 'com.google.code.gson:gson:2.8.1' compile 'com.squareup.retrofit2:retrofit:2.3.0' compile 'com.squareup.retrofit2:converter-gson:2.3.0' compile 'com.squareup.retrofit2:adapter-rxjava2:2.3.0' compile 'io.reactivex.rxjava2:rxjava:2.0.5' compile 'io.reactivex.rxjava2:rxandroid:2.0.1' }
In build.gradle project level:
dependencies { classpath 'com.android.tools.build:gradle:2.3.3' classpath 'me.tatarka:gradle-retrolambda:3.2.0' }
Implementation
User first authorize the application, so that they are able to post tweet from the app. For posting tweet statuses/update API endpoint of twitter is used and for attaching images with tweet media/upload API endpoint is used.
As, photos and location can be attached in a tweet, for Android Marshmallow and above we need to ask runtime permissions for camera, gallery and location. The related permissions are mentioned in Manifest file first
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> // for location <uses-feature android:name="android.hardware.location.gps"/> <uses-feature android:name="android.hardware.location.network"/>
If, the device is using an OS below Android Marshmallow, there will be no runtime permissions, the user will be asked permissions at the time of installing the app.
Now, runtime permissions are asked, if the user had already granted the permission the related activity (camera, gallery or location) is started.
For camera permissions, onClickCameraButton is called
@OnClick(R.id.camera) public void onClickCameraButton() { int permission = ContextCompat.checkSelfPermission( getActivity(), Manifest.permission.CAMERA); if (isAndroidMarshmallowAndAbove && permission != PackageManager.PERMISSION_GRANTED) { String[] permissions = { Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE }; requestPermissions(permissions, CAMERA_PERMISSION); } else { startCameraActivity(); } }
To start the camera activity if the permission is already granted, startCameraActivity method is called
private void startCameraActivity() { Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); File dir = getActivity().getExternalFilesDir(Environment.DIRECTORY_PICTURES); mCapturedPhotoFile = new File(dir, createFileName()); Uri capturedPhotoUri = getImageFileUri(mCapturedPhotoFile); intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedPhotoUri); startActivityForResult(intent, REQUEST_CAPTURE_PHOTO); }
If the user decides to save the photo clicked from camera activity, the photo should be saved by creating a file and its uri is required to display the saved photo. The filename is created using createFileName method
private String createFileName() { String timeStamp = new SimpleDateFormat("ddMMyyyy_HHmmss").format(new Date()); return "JPEG_" + timeStamp + ".jpg"; }
and uri is obtained using getImageFileUri
private Uri getImageFileUri(File file) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { return Uri.fromFile(file); } else { return FileProvider.getUriForFile(getActivity(), "org.loklak.android.provider", file); } }
Similarly, for the gallery, onClickGalleryButton method is implemented to ask runtime permissions and launch gallery activity if the permission is already granted.
@OnClick(R.id.gallery) public void onClickGalleryButton() { int permission = ContextCompat.checkSelfPermission( getActivity(), Manifest.permission.WRITE_EXTERNAL_STORAGE); if (isAndroidMarshmallowAndAbove && permission != PackageManager.PERMISSION_GRANTED) { String[] permissions = { Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE }; requestPermissions(permissions, GALLERY_PERMISSION); } else { startGalleryActivity(); } }
For starting the gallery activity, startGalleryActivity is used
private void startGalleryActivity() { Intent intent = new Intent(); intent.setType("image/*"); intent.setAction(Intent.ACTION_GET_CONTENT); intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); startActivityForResult( Intent.createChooser(intent, "Select images"), REQUEST_GALLERY_MEDIA_SELECTION); }
And finally for location onClickAddLocationButton is implemented
@OnClick(R.id.location) public void onClickAddLocationButton() { int permission = ContextCompat.checkSelfPermission( getActivity(), Manifest.permission.ACCESS_FINE_LOCATION); if (isAndroidMarshmallowAndAbove && permission != PackageManager.PERMISSION_GRANTED) { String[] permissions = {Manifest.permission.ACCESS_FINE_LOCATION}; requestPermissions(permissions, LOCATION_PERMISSION); } else { getLatitudeLongitude(); } }
If, the permission is already granted getLatitudeLongitude is called. Using LocationManager last known location is tried to obtain, if there is no last known location, current location is requested using a LocationListener.
private void getLatitudeLongitude() { mLocationManager = (LocationManager) getActivity().getSystemService(Context.LOCATION_SERVICE); // last known location from network provider Location location = mLocationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER); if (location == null) { // last known location from gps location = mLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER); } if (location != null) { // last known loaction available mLatitude = location.getLatitude(); mLongitude = location.getLongitude(); setLocation(); } else { // last known location not available mLocationListener = new TweetLocationListener(); // current location requested mLocationManager.requestLocationUpdates("gps", 1000, 1000, mLocationListener); } }
TweetLocationListener implements a LocationListener that provides the current location. If GPS is disabled, settings is launched so that user can enable GPS. This is implemented in onProviderDisabled callback of the listener.
private class TweetLocationListener implements LocationListener { @Override public void onLocationChanged(Location location) { mLatitude = location.getLatitude(); mLongitude = location.getLongitude(); setLocation(); } @Override public void onStatusChanged(String s, int i, Bundle bundle) { } @Override public void onProviderEnabled(String s) { } @Override public void onProviderDisabled(String s) { Intent intent = new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS); startActivity(intent); } }
If the user is asked for permissions, onRequestPermissionResult callback is invoked, if the permission is granted then the respective activities are opened or latitude and longitude are obtained.
@Override public void onRequestPermissionsResult( int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { boolean isResultGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; switch (requestCode) { case CAMERA_PERMISSION: if (grantResults.length > 0 && isResultGranted) { startCameraActivity(); } break; case GALLERY_PERMISSION: if (grantResults.length > 0 && isResultGranted) { startGalleryActivity(); } break; case LOCATION_PERMISSION: if (grantResults.length > 0 && isResultGranted) { getLatitudeLongitude(); } } }
Since, the camera and gallery activities are started to obtain a result i.e. photo(s). So, onActivityResult callback is called
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) { switch (requestCode) { case REQUEST_CAPTURE_PHOTO: if (resultCode == Activity.RESULT_OK) { onSuccessfulCameraActivityResult(); } break; case REQUEST_GALLERY_MEDIA_SELECTION: if (resultCode == Activity.RESULT_OK) { onSuccessfulGalleryActivityResult(data); } break; default: super.onActivityResult(requestCode, resultCode, data); } }
If the result of Camera activity is success i.e. the image is saved by the user. The saved image is displayed in a RecyclerView in TweetPostingFragment. This is implemented in onSuccessfulCameraActivityResult mehtod
private void onSuccessfulCameraActivityResult() { tweetMultimediaContainer.setVisibility(View.VISIBLE); Bitmap bitmap = BitmapFactory.decodeFile(mCapturedPhotoFile.getAbsolutePath()); mTweetMediaAdapter.clearAdapter(); mTweetMediaAdapter.addBitmap(bitmap); }
For a gallery activity, if a single image is selected then the uri of image can be obtained using getData method of an Intent. If multiple images are selected, the uri of images are stored in ClipData. After uris of images are obtained, it is checked if more than 4 images are selected as Twitter allows at most 4 images in a tweet. If more than 4 images are selected than the uris of extra images are removed. Using the uris of the images, the file is obtained and then from file Bitmap is obtained which is displayed in RecyclerView. This is implemented in onSuccessfulGalleryActivityResult
private void onSuccessfulGalleryActivityResult(Intent intent) { tweetMultimediaContainer.setVisibility(View.VISIBLE); Context context = getActivity(); // get uris of selected images ClipData clipData = intent.getClipData(); List<Uri> uris = new ArrayList<>(); if (clipData != null) { for (int i = 0; i < clipData.getItemCount(); i++) { ClipData.Item item = clipData.getItemAt(i); uris.add(item.getUri()); } } else { uris.add(intent.getData()); } // remove of more than 4 images int numberOfSelectedImages = uris.size(); if (numberOfSelectedImages > 4) { while (numberOfSelectedImages-- > 4) { uris.remove(numberOfSelectedImages); } Utility.displayToast(mToast, context, moreImagesMessage); } // get bitmap from uris of images List<Bitmap> bitmaps = new ArrayList<>(); for (Uri uri : uris) { String filePath = FileUtils.getPath(context, uri); Bitmap bitmap = BitmapFactory.decodeFile(filePath); bitmaps.add(bitmap); } // display images in RecyclerView mTweetMediaAdapter.setBitmapList(bitmaps); }
Now, to post images with tweet, first the ID of the image needs to be obtained using media/upload API endpoint, a multipart post request and then the obtained ID(s) is passed as the value of “media_ids” in statuses/update API endpoint. Since, there can be more than one image, a single observable is created for each image. The bitmap is converted to raw bytes for the multipart post request. As the process includes a network request and converting bitmap to bytes – a heavy resource consuming task which shouldn’t be on the main thread -, so an observable is created for the same as a result of which the tasks are performed concurrently i.e. in a separate thread.
private Observable<String> getImageId(Bitmap bitmap) { return Observable .defer(() -> { // convert bitmap to bytes ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream); byte[] bytes = byteArrayOutputStream.toByteArray(); RequestBody mediaBinary = RequestBody.create(MultipartBody.FORM, bytes); return Observable.just(mediaBinary); }) .flatMap(mediaBinary -> mTwitterMediaApi.getMediaId(mediaBinary, null)) .flatMap(mediaUpload -> Observable.just(mediaUpload.getMediaIdString())) .subscribeOn(Schedulers.newThread()); }
The tweet is posted when the “Tweet” button is clicked by invoking onClickTweetPostButton mehtod
@OnClick(R.id.tweet_post_button) public void onClickTweetPostButton() { String status = tweetPostEditText.getText().toString(); List<Bitmap> bitmaps = mTweetMediaAdapter.getBitmapList(); List<Observable<String>> mediaIdObservables = new ArrayList<>(); for (Bitmap bitmap : bitmaps) { // observables for images is created mediaIdObservables.add(getImageId(bitmap)); } if (mediaIdObservables.size() > 0) { // Post tweet with image postImageAndTextTweet(mediaIdObservables, status); } else if (status.length() > 0) { // Post text only tweet postTextOnlyTweet(status); } else { Utility.displayToast(mToast, getActivity(), tweetEmptyMessage); } }
Tweet containing images are posted by calling postImageAndTextTweet, once the tweet data is obtained, the data is cross posted to loklak server. The image IDs are obtained concurrently by using the zip operator.
private void postImageAndTextTweet(List<Observable<String>> imageIdObservables, String status) { mProgressDialog.show(); ConnectableObservable<StatusUpdate> observable = Observable.zip( imageIdObservables, mediaIdArray -> { String mediaIds = ""; for (Object mediaId : mediaIdArray) { mediaIds = mediaIds + String.valueOf(mediaId) + ","; } return mediaIds.substring(0, mediaIds.length() - 1); }) .flatMap(imageIds -> mTwitterApi.postTweet(status, imageIds, mLatitude, mLongitude)) .subscribeOn(Schedulers.io()) .publish(); Disposable postingDisposable = observable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(this::onSuccessfulTweetPosting, this::onErrorTweetPosting); mCompositeDisposable.add(postingDisposable); // cross posting to loklak server Disposable crossPostingDisposable = observable .flatMap(this::pushTweetToLoklak) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( push -> {}, t -> Log.e(LOG_TAG, "Cross posting failed: " + t.toString()) ); mCompositeDisposable.add(crossPostingDisposable); Disposable publishDisposable = observable.connect(); mCompositeDisposable.add(publishDisposable); }
In case of only text tweets, the text is obtained from editText and mediaIds are passed as null. And once the tweet data is obtained it is cross posted to loklak_server. This is executed by calling postTextOnlyTweet
private void postTextOnlyTweet(String status) { mProgressDialog.show(); ConnectableObservable<StatusUpdate> observable = mTwitterApi.postTweet(status, null, mLatitude, mLongitude) .subscribeOn(Schedulers.io()) .publish(); Disposable postingDisposable = observable .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(this::onSuccessfulTweetPosting, this::onErrorTweetPosting); mCompositeDisposable.add(postingDisposable); // cross posting to loklak server Disposable crossPostingDisposable = observable .flatMap(this::pushTweetToLoklak) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( push -> Log.e(LOG_TAG, push.getStatus()), t -> Log.e(LOG_TAG, "Cross posting failed: " + t.toString()) ); mCompositeDisposable.add(crossPostingDisposable); Disposable publishDisposable = observable.connect(); mCompositeDisposable.add(publishDisposable); }
Resources
- Runtime android permissions: https://developer.android.com/training/permissions/requesting.html
- Hot and cold observables (single observable and multiple subscribers): https://medium.com/@benlesh/hot-vs-cold-observables-f8094ed53339
- RxJava zip operator: http://reactivex.io/documentation/operators/zip.html