Making custom dialog boxes in PSLab app

This blog covers the topic of how to create custom dialog boxes in an Android app by taking an example of how it was implemented in PSLab Android application. The custom dialog box that will be illustrated in this blog will have a layout similar to that from my PR feat: added a guide for Logic Analyzer Instrument using Alert Dialog Box in PSLab Android repository. But all the main functionalities that can be added to a custom dialog will be illustrated in this blog.

How custom dialog was implemented in the PSLab app?

  • The first step is to make a layout for the custom dialog box. The layout of custom dialog should be such that it shouldn’t occupy the whole screen as the idea here is to overlay the dialog over the activity and not replace the whole activity with the dialog layout. The layout can be implemented by using the following attributes :
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
   android:layout_width="match_parent"
   android:layout_height="match_parent">

   <RelativeLayout
       android:layout_width="match_parent"
       android:layout_height="wrap_content">

       <TextView
           android:id="@+id/custom_dialog_text"
           android:layout_width="match_parent"
           android:layout_height="wrap_content"/>

       <ImageView
           android:id="@+id/custom_dialog_schematic"
           android:layout_width="wrap_content"
           android:layout_height="@dimen/schematic_height" />

       <TextView
           android:id="@+id/description_text"
           android:layout_width="match_parent"
           android:layout_height="wrap_content" />

       <CheckBox
           android:id="@+id/toggle_show_again"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content" />

       <RelativeLayout
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           android:layout_below="@id/toggle_show_again">

           <Button
               android:id="@+id/dismiss_button"
               android:layout_width="wrap_content"
               android:layout_height="wrap_content" />

       </RelativeLayout>
   </RelativeLayout>
</ScrollView>

The result of the above layout after adding proper margins and padding is as shown in figure 1.

Figure 1. The output of the layout made for custom dialog

  • Now as the layout is ready, we can focus on adding Java code to inflate the layout over a particular activity. Make a function in the activity or the fragment in which the custom dialog is to be inflated. Add the following code (if the layout is same as that in PSLab app else modify the code as per the layout) in the function to display the custom dialog box.
public void howToConnectDialog(String title, String intro, int iconID, String desc) {
   try {
       final AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
       LayoutInflater inflater = getLayoutInflater();
       View dialogView = inflater.inflate(R.layout.custom_dialog_box, null);

 // Find and set all the attributes used in the layout and then create the dialog as shown
       
       builder.setView(dialogView);
       builder.setTitle(title);
       final AlertDialog dialog = builder.create();
       ok_button.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
                dialog.dismiss();
           }
       });
       dialog.show();
   } catch (Exception e) {
       e.printStackTrace();
   }
}

Here, the LayoutInflater inflates the custom dialog layout in the Alertdialog builder. The Alertdialog builder is used to hold and set values for all the views present in the inflated layout. The builder then creates a final custom dialog when builder.create() method is called. Finally, the dialog is shown by calling method dialog.show() and is dismissed by calling method dialog.dismiss().

  • Now the dialog box is ready and it will be inflated when the above method is called. But the “Don’t show again” checkbox currently has no functionality and so the dialog box will pop up although “Don’t show again” checkbox is ticked mark by the user. For adding this functionality, we will need to use Shared Preferences to save the state of the dialog box. For using Shared Preferences, first, add the following lines of code in the above method
final SharedPreferences settings = getActivity().getSharedPreferences(PREFS_NAME, 0);
Boolean skipMessage = settings.getBoolean("skipMessage", false);

The PREFS_NAME is the key to distinctly identify the state of the given dialog box from the stored state of many dialog boxes (if any). A suggested key name is “customDialogPreference”. The skipMessage is a boolean used here to check the state if the dialog box should be inflated or not.

Now format the onClickListener of the OK button with the following code :

Boolean checkBoxResult = false;
if (dontShowAgain.isChecked())
   checkBoxResult = true;
SharedPreferences.Editor editor = settings.edit();
editor.putBoolean("skipMessage", checkBoxResult);
editor.apply();
dialog.dismiss();

The following code checks the state of “Don’t show again” dialog and then inflates the custom dialog if applicable. If the checkbox is ticked mark then on pressing the OK button the dialog won’t be inflated again.

Resources

  1. https://developer.android.com/guide/topics/ui/dialogs – Android Documentation on Dialogs (Great Resource to make a custom dialog)
Continue Reading

Move Photos from one Path to Another in Phimpme Android Application

In the Phimpme Android application, users can perform various operations on images such as editing an image, sharing an image, printing a pdf version of the image and many more. However, another important functionality that has been implemented is the option to move an image from one path(folder) to another. So in this blog post, I will be discussing how we achieved the functionality to move any image.

Step 1

First, we need to add an option in the overflow menu to move any no of images from one folder to another. The option to move an image has been added by implementing the following lines of code in the  menu_view_pager.xml file.

<item
  android:id=“@+id/action_move”
  app:showAsAction=“never”
  android:title=“Move”/>

Step 2

Now after the user opts for the move operation, a bottomSheetDialog containing all the albums/folders in the storage to choose for moving the photos into will be displayed. Once the user selects the required location to move the photos to, a method moveSelectedMedia would be invoked passing-in the context and the destination path as the parameters. Thereafter inside the method moveSelectedMedia, first a check would be performed to determine whether the selected photos(to move) are already present in the folder(path folder and destination folder both are same), if true the operation would be terminated as the photos are already present in the destination folder, and if false the selected photos would be moved to the destination folder one by one by the use of another method moveMedia. The code snippets used to implement the moveSelectedMedia is provided below.

public int moveSelectedMedia(Context context, String targetDir) {
 int n = 0;
 try
 {
    int index=-1;
    for(int i =0;i<selectedMedias.size();i++)
    {
       String s = selectedMedias.get(i).getPath();
       int indexOfLastSlash = s.lastIndexOf(“/”);
       String fileName = s.substring(indexOfLastSlash + 1);

       if(!selectedMedias.get(i).getPath().equals(targetDir+“/”+fileName)){
          index=-1;
       }else{
          index=i;
          break;

       }
    }
    if(index!=-1)
    {
       n = –1;
    }else{
       for (int i = 0; i < selectedMedias.size(); i++) {

          if (moveMedia(context, selectedMedias.get(i).getPath(), targetDir)) {
             String from = selectedMedias.get(i).getPath();
             scanFile(context, new String[]{ from, StringUtils.getPhotoPathMoved(selectedMedias.get(i).getPath(), targetDir) },
                   new MediaScannerConnection.OnScanCompletedListener() {
                      @Override
                      public void onScanCompleted(String s, Uri uri) {
                         Log.d(“scanFile”, “onScanCompleted: “ + s);
                      }
                   });
             media.remove(selectedMedias.get(i));
             n++;
          }
       }
       setCount(media.size());
    }
 } catch (Exception e) { e.printStackTrace(); }
 return n;
}

Step 3

In this step, I’d discuss the use and implementation of the moveMedia method mentioned in the earlier step. So the moveMedia is used to move a single photo to a particular destination, taking-in the context, the oldpath of the photo and the destination folder as parameters. Inside this method another method moveFile of the ContentHelper class is invoked passing-in the context, File object of the photo and File object of the destination folder as parameters. Now inside the moveFile method the actual move operation takes place. First the photo is tried to move by doing a normal rename operation by using the renameTo function of the File class. The success of the rename operation updates the path of the photo to the destination path and deletes the original path of the photo whereas if the normal rename operation fails, then the photo is moved by first performing a copy operation to copy the photo to the destination folder and then deleting the original photo. The code snippets used to implement the moveMedia and moveFile functions are provided below.

private boolean moveMedia(Context context, String source, String targetDir) {
 File from = new File(source);
 File to = new File(targetDir);
 return ContentHelper.moveFile(context, from, to);
}
public static boolean moveFile(Context context, @NonNull final File source, @NonNull final File targetDir) {
 // First try the normal rename.
 File target = new File(targetDir, source.getName());

 boolean success = source.renameTo(target);

 if (!success) {
    success = copyFile(context, source, targetDir);
    if (success) {
       success = deleteFile(context, source);
    }
 }

 //if (success) scanFile(context, new String[]{ source.getPath(), target.getPath() });
 return success;
}

And at last, after the photos are moved successfully the media adapter is called with the updated photos list to display thus displaying the remaining photos in the folder.

This is how we have implemented the functionality to move images from one path to another in the Phimpme Android application. To get the full source code, please refer to the Phimpme Android Github repository listed in the resource section below.

Resources

1. Android Developer documentation –              https://developer.android.com/reference/java/io/File

2. Github-Phimpme Android Repository – https://github.com/fossasia/phimpme- android/

3. Renaming a file in java – http://stacktips.com/tutorials/java/how-to-delete-and-rename-a-file-in-java

Continue Reading

Implementing Trash Bin in Phimpme Android Application

In the Phimpme Android application, users can perform various operations/actions on the photos available, some of the important operations involve renaming an image, sharing an image, deleting image etc. However, when the user performs delete operation for image/images the images are permanently deleted from the storage and there is no option to restore/recover the deleted image. Now imagine a situation where the user accidentally or in a rush deletes photo/photos, and now there is no possible way for the user to recover those accidentally deleted photos, so in such circumstances, the Trash Bin feature could prove to be a breather for the user. So, in this blog post, I will be discussing the implementation of the Trash Bin feature.

Step 1

Firstly, we need to implement the functionality to move the images to bin folder whenever the delete operation is performed. For this, we’d programmatically create a folder naming it .nomedia, so that this folder is not picked up by any other similar application while scanning folders. Now when the user deletes images, a check would be performed first to determine whether the bin folder already exists or not and actions would be performed accordingly(a folder would be created if it doesn’t already exist and the selected photos for deletion would be moved to the bin folder or the deleted photos would be moved directly to the bin folder if its there). Code snippets used to implement the bin folder is provided below.

private boolean addToTrash(){
  int no = 0;
  boolean succ = false;
  File file = new File(Environment.getExternalStorageDirectory() + “/” + “.nomedia”);
  if(file.exists() && file.isDirectory()){
      if(!all_photos && !fav_photos && editMode){
          no = getAlbum().moveSelectedMedia(getApplicationContext(), file.getAbsolutePath());
      }else if(all_photos && !fav_photos && editMode){
          no = getAlbum().moveAllMedia(getApplicationContext(), file.getAbsolutePath(), selectedMedias);
      }else if(!editMode && !all_photos && !fav_photos){
          no = getAlbum().moveAllMedia(getApplicationContext(), file.getAbsolutePath(), getAlbum().getMedia());
      }
  else{
      if(file.mkdir()){
          if(!all_photos && !fav_photos && editMode){
              no = getAlbum().moveSelectedMedia(getApplicationContext(), file.getAbsolutePath());
          }else if(all_photos && !fav_photos && editMode){
              no = getAlbum().moveAllMedia(getApplicationContext(), file.getAbsolutePath(), selectedMedias);
          }else if(!editMode && !all_photos && !fav_photos){
              no = getAlbum().moveAllMedia(getApplicationContext(), file.getAbsolutePath(), getAlbum().getMedia());
          }
         // no = getAlbum().moveSelectedMedia(getApplicationContext(), file.getAbsolutePath());
       }
 // clearSelectedPhotos();
  return succ;
}

Step 2

Now if all the photos(selected by the user for deletion) are successfully moved to the bin folder, realm objects corresponding to those image files would be created and added to the realm database. The corresponding Realm object would consist of attributes namely oldpath, trashbinpath, time of delete and duration. The realm objects would be used at the time of implementing restore functionality for the trash bin images. The realm model class representing the Trash bin object is provided below.

public class TrashBinRealmModel extends RealmObject {

  @PrimaryKey
  private String trashbinpath;
  private String oldpath;
  private String datetime;
  private String timeperiod;

  public TrashBinRealmModel(){

  }

  public TrashBinRealmModel(String oldpath, String newpath, String datetime, String timeperiod){
      this.oldpath = oldpath;
      this.trashbinpath = newpath;
      this.datetime = datetime;
      this.timeperiod = timeperiod;
  }

  public void setTrashbinpath(String trashbinpath){
      this.trashbinpath = trashbinpath;
  }

  public String getTrashbinpath(){
      return trashbinpath;
  }
  public void setDatetime(String datetime){
      this.datetime = datetime;
  }

  public String getDatetime(){
      return datetime;
  }

  public void setOldpath(String oldpath){
      this.oldpath = oldpath;
  }

  public String getOldpath(){
      return oldpath;
  }

  public void setTimeperiod(String timeperiod){
      this.timeperiod = timeperiod;
  }

  public String getTimeperiod(){
      return timeperiod;
  }
}

The implementation for the function to add trash bin objects to Realm database is provided below.

for(int i = 0; i < media.size(); i++){
  int index = media.get(i).getPath().lastIndexOf(“/”);
  String name = media.get(i).getPath().substring(index + 1);
  realm.beginTransaction();
  String trashpath = trashbinpath + “/” + name;
  TrashBinRealmModel trashBinRealmModel =   realm.createObject(TrashBinRealmModel.class, trashpath);
  trashBinRealmModel.setOldpath(media.get(i).getPath());
  trashBinRealmModel.setDatetime(new SimpleDateFormat(“dd/MM/yyyy HH:mm:ss”).format(new Date()));
  trashBinRealmModel.setTimeperiod(“null”);
  realm.commitTransaction();
}

Step 3

Now we need to provide an option to the user to navigate to the Trash bin section to perform the delete(permanently) or restore option. So for the aforementioned action, we’d be adding an option in the navigation drawer menu titled “Trash Bin” clicking on which the Trash Bin section would be displayed to the user. The XML code changes committed to implementing the option has been provided below.

<LinearLayout xmlns:android=“http://schemas.android.com/apk/res/android”
            android:id=“@+id/ll_drawer_trashbin”
            android:layout_width=“match_parent”
            android:layout_height=“wrap_content”
            android:background=“@drawable/ripple”
            android:clickable=“true”
            android:orientation=“horizontal”>

  <com.mikepenz.iconics.view.IconicsImageView
      android:id=“@+id/Drawer_trash_Icon”
      android:layout_width=“@dimen/icon_width_height”
      android:layout_height=“@dimen/icon_width_height”
      android:layout_gravity=“center_vertical”
      android:layout_marginLeft=“@dimen/big_spacing”
      android:layout_marginRight=“@dimen/big_spacing”
      app:iiv_icon=“gmd-file-upload”/>

  <TextView
      android:id=“@+id/Drawer_trash_Item”
      android:layout_width=“wrap_content”
      android:layout_height=“wrap_content”
      android:paddingBottom=“16dp”
      android:paddingTop=“16dp”
      android:text=“Trash Bin”
      android:textColor=“@color/md_dark_background”
      android:textSize=“16sp”/>
</LinearLayout>

This is how we have implemented the functionality to rename an image in the Phimpme Android application. To get the full source code, please refer to the Phimpme Android Github repository listed in the resource section below.

Resources

1. Android Developer documentation –https://developer.android.com/reference/java/io/File

2. Github-Phimpme Android Repository –https://github.com/fossasia/phimpme-android/

3. Realm for Android tutorial –https://www.androidhive.info/2016/05/android-working-with-realm-database-replacing-sqlite-core-data/

4. Hiding directories using .nomedia file –http://www.easycodeway.com/2016/08/hide-files-in-android-using-nomedia-file.html

Continue Reading

Option to pin albums in Phimpme Android Application

In the Phimpme Android application, users are provided with options to perform various operations on the albums available such as move, creating a zip file of the album, rename an album and many more. However, one another useful functionality that has been added to the Phimpme Android application is the option to pin albums. So in this post, I will be discussing the implementation of the pin to top functionality.

Step 1

First, we need to add an option in the overflow menu to pin the album which has been selected, to the top. The pin to top option can be added in the overflow menu by implementing the following lines of code in the menu_albums.xml file(This file contains the overflow menu options for albums).

<item
  android:id=“@+id/set_pin_album”
  android:title=“@string/pin”
  app:showAsAction=“never” />

Step 2

Now when the user selects the option to pin any album to the top, the user’s choice of the selected album is retrieved by the getSelectedAlbum method of Handling Albums class. Thereafter the togglePin method of class Album Settings is invoked, passing in the context as a parameter. In the togglePin method an instance of the CustomHelper class is obtained first, then its pinAlbum method is called passing in the album path and a variable pinned as the parameters. Here depending on the boolean value of the variable pinned, the selected album will be pinned or unpinned. The code snippets used to implement getSelectedAlbum and togglePin method are provided below.

public Album getSelectedAlbum(int index) {
return selectedAlbums.get(index);
}
public void togglePin(Context context) {
this.pinned = !pinned;
CustomAlbumsHelper h = CustomAlbumsHelper.getInstance(context);
h.pinAlbum(path, pinned);
}

The getSelectedAlbum method and the togglePin method are subsequently used by the following line of code.

getAlbums().getSelectedAlbum(0).settings.togglePin(getApplicationContext());

Step 3

In this step, I’d discuss the implementation of the pinAlbum method invoked inside the togglePin method. In the pinAlbum method, first a writable instance of album_settings SQLite database is obtained. The album_settings database maintains information about the albums in a table with the name of the table Albums and the columns representing info about the album such as path, excluded, pinned, sortorder etc. Next, the checkAndCreateAlbum method would be invoked passing in the writable instance of the database and path of the album as parameters. Now inside the checkAndCreateAlbum method, a check would be performed to determine whether a column representing info about the selected album is already present or not, if not a row is created for the selected album. The code snippet used to implement the checkAndCreateAlbum is provided below.

private void checkAndCreateAlbum(SQLiteDatabase db, String path) {

  Cursor cursor = db.query(TABLE_ALBUMS, null,  ALBUM_PATH+“=?”,
          new String[]{ path }, null, null, null);

  if (cursor.getCount() == 0) {
      ContentValues values = new ContentValues();
      values.put(ALBUM_PATH, path);
      values.put(ALBUM_SORTING_MODE, SortingMode.DATE.getValue());
      values.put(ALBUM_SORTING_ORDER, SortingOrder.DESCENDING.getValue());
      values.put(ALBUM_EXCLUDED, 0);
      db.insert(TABLE_ALBUMS, null, values);
  }

  cursor.close();
}

Now in the pinAlbum method through the writable instance of the album_settings database, the pinned information of the selected album is updated in the Albums table accordingly. The code snippets used to implement the pinAlbum method is provided below.

void pinAlbum(String path, boolean status) {
  SQLiteDatabase db = this.getWritableDatabase();
  checkAndCreateAlbum(db, path);
  ContentValues values = new ContentValues();
  values.put(ALBUM_PINNED, status ? 1 : 0);
  db.update(TABLE_ALBUMS, values, ALBUM_PATH+“=?”, new String[]{ path });
  db.close();
}

At last, the notifyDataSetChanged method of the AlbumsAdapter class would be called so as to display the pinned albums at the top irrespective of the sorting order.

A screenshot displaying the pinned albums at the top in albums view is provided below.

This is how we have implemented the functionality to pin an album to top in the Phimpme Android application. To get the full source code, please refer to the Phimpme Android Github repository listed in the resource section below.

Resources

1. Android Developer documentation –https://developer.android.com/reference/android/database/sqlite/SQLiteDatabase

2. Github-Phimpme Android Repository – https://github.com/fossasia/phimpme-android/

3. Sqlite database tutorial for android –https://www.androidhive.info/2011/11/android-sqlite-database-tutorial/.

Continue Reading

Displaying Skills Feedback on SUSI.AI Android App

SUSI.AI has a feedback system where the user can post feedback for a skill using Android, iOS, and web clients. In skill details screen, the feedback posted by different users is displayed. This blog shows how the feedback from different users can be displayed in the skill details screen under feedback section.

Three of the items from the feedback list are displayed in the skill details screen. To see the entire list of feedback, the user can tap the ‘See All Reviews’ option at the bottom of the list.

The API endpoint that has been used to get skill feedback from the server is https://api.susi.ai/cms/getSkillFeedback.json

The following query params are attached to the above URL to get the specific feedback list :

  • Model
  • Group
  • Language
  • Skill Name

The list received is an array of `Feedback` objects, which hold three values :

  • Feedback String (feedback) – Feedback string posted by a user
  • Email (email) – Email address of the user who posted the feedback
  • Time Stamp – Time of posting feedback

To display feedback, use the RecyclerView. There can be three possible cases:

  • Case – 1: Size of the feedback list is greater than three
    In this case, set the size of the list to three explicitly in the FeedbackAdapter so that only three view holders are inflated. Inflate the fourth view holder with “See All Reviews” text view and make it clickable if the size of the received feedback list is greater than three.
    Also, when the user taps “See All Reviews”, launch an explicit intent to open the Feedback Activity. Set the AllReviewsAdapter for this activity. The size of the list will not be altered here because this activity must show all feedback.
  • Case – 2: Size of the feedback list is less than or equal to three
    In this case simply display the feedback list in the SkillDetailsFragment and there is no need to launch any intent here. Also, “See All Reviews” will not be displayed here.

    Case – 3: Size of the feedback list is zero
    In this case simply display a message that says no feedback has been submitted yet.Here is an example of how a “See All Reviews” screen looks like :

Implementation

First of all, define an XML layout for a feedback item and then create a data class for storing the query params.

data class FetchFeedbackQuery(
       val model: String,
       val group: String,
       val language: String,
       val skill: String
)


Now, make the GET request using Retrofit from the model (M in MVP).

override fun fetchFeedback(query: FetchFeedbackQuery, listener: ISkillDetailsModel.OnFetchFeedbackFinishedListener) {

   fetchFeedbackResponseCall = ClientBuilder.fetchFeedbackCall(query)

   fetchFeedbackResponseCall.enqueue(object : Callback<GetSkillFeedbackResponse> {
       override fun onResponse(call: Call<GetSkillFeedbackResponse>, response: Response<GetSkillFeedbackResponse>) {
           listener.onFetchFeedbackModelSuccess(response)
       }

       override fun onFailure(call: Call<GetSkillFeedbackResponse>, t: Throwable) {
           Timber.e(t)
           listener.onFetchFeedbackError(t)
       }
   })
}

override fun cancelFetchFeedback() {
   fetchFeedbackResponseCall.cancel()
}


The feedback list received in the JSON response can now be used to display the user reviews with the help of custom adapters, keeping in mind the three cases already discussed above.

Resources

Continue Reading

Add Unit Test in SUSI.AI Android App

Unit testing is an integral part of software development. Hence, this blog focuses on adding unit tests to SUSI.AI Android app. To keep things simple, take a very basic example of anonymize feedback section. In this section the email of the user is truncated after ‘@’ symbol in order to maintain the anonymity of the user. Here is the function that takes ‘email’ as a parameter and returns the truncated email that had to be displayed in the feedback section :

fun truncateEmailAtEnd(email: String?): String? {
   if (!email.isNullOrEmpty()) {
       val truncateAt = email?.indexOf('@')
       if (truncateAt is Int && truncateAt != -1) {
           return email.substring(0, truncateAt.plus(1)) + " ..."
       }
   }
   return null
}

 

The unit test has to be written for the above function.

Step – 1 : Add the following dependencies to your build.gradle file.

//unit test
testImplementation "junit:junit:4.12"
testImplementation "org.mockito:mockito-core:1.10.19"

 

Step – 2 : Add a file in the correct package (same as the file to be tested) in the test package. The function above is present in the Utils.kt file. Thus create a file, called UtilsTest.kt, in the test folder in the package org.fossasia.susi.ai.helper’.

Step – 3 : Add a method, called testTruncateEmailAtEnd(), to the UtilsTest.kt and add ‘@Test’ annotation to before this method.

Step – 4 : Now add tests for various cases, including all possible corner cases that might occur. This can be using assertEquals() which takes in two paramters – expected value and actual value.

For example, consider an email ‘[email protected]’. This email is passed as a parameter to the truncateAtEnd() method. The expected returned string would be ‘[email protected] …’. So, add a test for this case using assertEquals() as :

assertEquals("[email protected] ...", Utils.truncateEmailAtEnd("[email protected]"))

 

Similary, add other cases, like empty email string, null string, email with numbers and symbols and so on.

Here is how the UtilsTest.kt class looks like.

package org.fossasia.susi.ai.helper

import junit.framework.Assert.assertEquals
import org.junit.Test

class UtilsTest {
   @Test
   fun testTruncateEmailAtEnd() {
       assertEquals("[email protected] ...", Utils.truncateEmailAtEnd("[email protected]"))
       assertEquals(null, Utils.truncateEmailAtEnd("testuser"))
       assertEquals(null, Utils.truncateEmailAtEnd(""))
       assertEquals(null, Utils.truncateEmailAtEnd(" "))
       assertEquals(null, Utils.truncateEmailAtEnd(null))
       assertEquals("[email protected] ...", Utils.truncateEmailAtEnd("[email protected]"))
       assertEquals("[email protected] ...", Utils.truncateEmailAtEnd("[email protected]"))
       assertEquals("[email protected] ...", Utils.truncateEmailAtEnd("[email protected]"))
       assertEquals(null, Utils.truncateEmailAtEnd("test [email protected]"))
   }
}

 

Note: You can add more tests to check for other general and corner cases.

Step – 5 : Run the tests in UtilsTest.kt.

If all the test cases pass, then the tests pass. But, if the tests fail, try to figure out the cause of failure of the tests and add/modify the code in the Utils.kt accordingly. This approach helps recognize flaws in the existing code thereby reducing the risk of bugs and failures.

Resources

Continue Reading

Display skills sorted in different orders in SUSI.AI Android App

Skills in SUSI.AI were displayed in a random order earlier as per the response received from the server. To provide more flexibility to the users, the skills can be sorted by various orders like top-rated, lexicographical, recently updated and so on. This blog shows how to get sorted skills from the server using the getSkillList.json API.

API Information

For requesting a list of SUSI skills, the endpoint used is /cms/getSkillList.json.
This will give you the sorted skills as per the applied filter. Some of the filters include top rated skills, recently updated skills, newly created skills, etc.

Base URL : https://api.susi.ai/cms/getSkillList.json

Parameters to be passed :

  • group – This is the group to which a skill belongs to.
  • language – The language in which the skill is needed.
  • applyFilter – This parameter tells if the filtering needs to be enabled.
  • filter_type – This is the order in which the skills need to be sorted and is applicable if applyFilter is true.
  • filter_name – This tells whether the order of sorting needs to be ascending or descending and is applicable if applyFilter is true.

Currently, there are following filters available :

  • Top Rated : The skills will be sorted based on the skills ratings by users.

filter_type :  rating
filter_name : ascending or descending (based on the requirement).

 

  • Lexicographical : The skills will be sorted in alphabetical order.

filter_type :  lexicographical
filter_name : ascending (to show skills in the order A-Z) or (descending to show skills in the order Z-A).

 

  • Newly Created : The skills will be displayed based on the date of creation.

filter_type :  creation_date
filter_name : ascending to show newly created skills first and descending to show the oldest created skills first.

 

  • Recently Updated : The skills will be sorted based on the date when they were last updated.

filter_type :  modified_date
filter_name : ascending or descending as per requirement.

 

  • Feedback Count : The skills will be sorted as per the feedback count.

filter_type :  feedback
filter_name : ascending to show skills with the most number of feedbacks first and descending to show skills with the least number of feedbacks first.

 

  • This Week Usage : The skills will be sorted as per the usage analytics of the week.

filter_type :  usage
duration : 7
filter_name : descending to show the most used skill first and vice-versa.

 

  • This Week Usage : The skills will be sorted as per the usage analytics for the last 30 days.

filter_type :  usage
duration : 30
filter_name : descending to show the most used skill first and vice-versa.

 

Note: In all the above cases, the ‘applyFilter’ param will be passed with the value ‘true’ otherwise the skills will not be sorted.

Here is an example of a URL for displaying the top rated skills:

https://api.susi.ai/cms/getSkillList.json?group=All&language=en&applyFilter=true&filter_name=descending&filter_type=rating

 

To make a request to the getSkillList.json API, make a GET request as follows :

@GET("/cms/getSkillList.json")
Call<ListSkillsResponse> fetchListSkills(@QueryMap Map<String, String> query);

 

Here the query map contains all the aforementioned params.

Now, make the GET request using Retrofit from the model :

private lateinit var authResponseCallSkills: Call<ListSkillsResponse>

override fun fetchSkills(group: String, language: String, listener: IGroupWiseSkillsModel.OnFetchSkillsFinishedListener) {
   val queryObject = SkillsListQuery(group, language, "true", "descending", "rating")
   authResponseCallSkills = ClientBuilder.fetchListSkillsCall(queryObject)

   authResponseCallSkills.enqueue(object : Callback<ListSkillsResponse> {
       override fun onResponse(call: Call<ListSkillsResponse>, response: Response<ListSkillsResponse>) {
           listener.onSkillFetchSuccess(response, group)
       }

       override fun onFailure(call: Call<ListSkillsResponse>, t: Throwable) {
           Timber.e(t)
           listener.onSkillFetchFailure(t)
       }
   })
}

override fun cancelFetch() {
   try {
       authResponseCallSkills.cancel()
   } catch (e: Exception) {
       Timber.e(e)
   }
}

 

The skills in the filteredData array, received in the JSON response, shall be sorted in the order based on the filter_type and filter_name params that you passed. Now, this array can be used to display skills on the skills listing page.

Resources

Continue Reading

Showing skills based on different metrics in SUSI Android App using Nested RecyclerViews

SUSI.AI Android app had an existing skills listing page, which displayed skills under different categories. As a result, there were a number of API calls at almost the same time, which led to slowing down of the app. Thus, the UI of the Skill Listing page has been changed so as to reduce the number of API calls and also to make this page more useful to the user.

API Information

For getting a list of SUSI skills based on various metrics, the endpoint used is /cms/getSkillMetricsData.json

This will give you top ten skills for each metric. Some of the metrics include skill ratings, feedback count, etc. Sample response for top skills based on rating :

"rating": [
  {
    "model": "general",
    "group": "Knowledge",
    "language": "en",
    "developer_privacy_policy": null,
    "descriptions": "A skill to tell atomic mass and elements of periodic table",
    "image": "images/atomic.png",
    "author": "Chetan Kaushik",
    "author_url": "https://github.com/dynamitechetan",
    "author_email": null,
    "skill_name": "Atomic",
    "protected": false,
    "reviewed": false,
    "editable": true,
    "staffPick": false,
    "terms_of_use": null,
    "dynamic_content": true,
    "examples": ["search for atomic mass of radium"],
    "skill_rating": {
      "bookmark_count": 0,
      "stars": {
        "one_star": 0,
        "four_star": 3,
        "five_star": 8,
        "total_star": 11,
        "three_star": 0,
        "avg_star": 4.73,
        "two_star": 0
      },
      "feedback_count": 3
    },
    "usage_count": 0,
    "skill_tag": "atomic",
    "supported_languages": [{
      "name": "atomic",
      "language": "en"
    }],
    "creationTime": "2018-07-25T15:12:25Z",
    "lastAccessTime": "2018-07-30T18:50:41Z",
    "lastModifiedTime": "2018-07-25T15:12:25Z"
  },
  .
  .

]

 

Note : The above response shows only one of the ten objects. There will be ten such skill metadata objects inside the “rating” array. It contains all the details about skills.

Implementation in SUSI.AI Android App

Skill Listing UI of SUSI SKill CMS

Skill Listing UI of SUSI Android App

The UI of skills listing in SUSI Android app displays skills for each metric in a horizontal recyclerview, nested in a vertical recyclerview. Thus, for implementing horizontal recyclerview inside vertical recyclerview, you need two viewholders and two adapters (one each for a recyclerview). Let us go through the implementation.

  • Make a query object consisting of the model and language query parameters that shall be passed in the request to the server.

val queryObject = SkillMetricsDataQuery("general", 
PrefManager.getString(Constant.LANGUAGE,Constant.DEFAULT))

 

  • Fetch the skills based on metrics, by calling fetch in SkillListModel which then makes an API call to fetch groups.

skillListingModel.fetchSkillsMetrics(queryObject, this)

 

  • When the API call is successful, the below mentioned method is called which in turn parses the received response and updates the adapter to display the skills based on different metrics.

override fun onSkillMetricsFetchSuccess(response: Response<ListSkillMetricsResponse>) {
   skillListingView?.visibilityProgressBar(false)
   if (response.isSuccessful && response.body() != null) {
       Timber.d("METRICS FETCHED")
       metricsData = response.body().metrics
       if (metricsData != null) {
           metrics.metricsList.clear()
           metrics.metricsGroupTitles.clear()
           if (metricsData?.rating != null) {
               if (metricsData?.rating?.size as Int > 0) {
                   metrics.metricsGroupTitles.add(utilModel.getString(R.string.metric_rating))
                   metrics.metricsList.add(metricsData?.rating)
                   skillListingView?.updateAdapter(metrics)
               }
           }

           if (metricsData?.usage != null) {
               if (metricsData?.usage?.size as Int > 0) {
                   metrics.metricsGroupTitles.add(utilModel.getString(R.string.metric_usage))
                   metrics.metricsList.add(metricsData?.usage)
                   skillListingView?.updateAdapter(metrics)
               }
           }

           if (metricsData?.newest != null) {
               val size = metricsData?.newest?.size
               if (size is Int) {
                   if (size > 0) {
                       metrics.metricsGroupTitles.add(utilModel.getString(R.string.metric_newest))
                       metrics.metricsList.add(metricsData?.newest)
                       skillListingView?.updateAdapter(metrics)
                   }
               }
           }

           if (metricsData?.latest != null) {
               if (metricsData?.latest?.size as Int > 0) {
                   metrics.metricsGroupTitles.add(utilModel.getString(R.string.metric_latest))
                   metrics.metricsList.add(metricsData?.latest)
                   skillListingView?.updateAdapter(metrics)
               }
           }

           if (metricsData?.feedback != null) {
               if (metricsData?.feedback?.size as Int > 0) {
                   metrics.metricsGroupTitles.add(utilModel.getString(R.string.metric_feedback))
                   metrics.metricsList.add(metricsData?.feedback)
                   skillListingView?.updateAdapter(metrics)
               }
           }

           if (metricsData?.topGames != null) {
               val size = metricsData?.feedback?.size
               if (size is Int) {
                   if (size > 0) {
                       metrics.metricsGroupTitles.add(utilModel.getString(R.string.metrics_top_games))
                       metrics.metricsList.add(metricsData?.topGames)
                       skillListingView?.updateAdapter(metrics)
                   }
               }
           }

           skillListingModel.fetchGroups(this)
       }
   } else {
       Timber.d("METRICS NOT FETCHED")
       skillListingView?.visibilityProgressBar(false)
       skillListingView?.displayError()
   }
}

 

  • When skills are fetched, the data in adapter is updated using skillMetricsAdapter.notifyDataSetChanged()

override fun updateAdapter(metrics: SkillsBasedOnMetrics) {
   swipe_refresh_layout.isRefreshing = false
   if (errorSkillFetch.visibility == View.VISIBLE) {
       errorSkillFetch.visibility = View.GONE
   }
   skillMetrics.visibility = View.VISIBLE
   this.metrics.metricsList.clear()
   this.metrics.metricsGroupTitles.clear()
      this.metrics.metricsList.addAll(metrics.metricsList)
   this.metrics.metricsGroupTitles.addAll(metrics.metricsGroupTitles)
      skillMetricsAdapter.notifyDataSetChanged()
}

 

  • The data is set to the layout in two adapters made earlier. The following is the code to set the title for the metric and adapter to horizontal recyclerview. This is the SkillMetricsAdapter to set data to show item in vertical recyclerview.

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
   if (metrics != null) {
       if (metrics.metricsList[position] != null) {
           holder.groupName?.text = metrics.metricsGroupTitles[position]
       }

       skillAdapterSnapHelper = StartSnapHelper()
       holder.skillList?.setHasFixedSize(true)
       val mLayoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
       holder.skillList?.layoutManager = mLayoutManager
       holder.skillList?.adapter = SkillListAdapter(context, metrics.metricsList[position], skillCallback)
       holder.skillList?.onFlingListener = null
       skillAdapterSnapHelper.attachToRecyclerView(holder.skillList)
   }
}

 

Continue Reading

Show skills image in Circular Image View in SUSI.AI Android app

Each SUSI.AI skill has some data like skill name, skill image, skill rating and so on. Some of the skills image have a square appearance while others have a circular appearance and so on. This blog shows how to transform all images to circular image view while setting the skill image in the appropriate view holder in the skills card using Picasso.

Step – 1 : Create a new helper class called CircleTransform.java that implements the Transformation interface from Picasso.

Step -2 : Override the transform and key methods.

Step – 3 : Create a Bitmap and perform the following steps inside the transform() method, as mentioned in the code below :

@Override
public Bitmap transform(Bitmap source) {
   int size = Math.min(source.getWidth(), source.getHeight());
   int x = (source.getWidth() - size) / 2;
   int y = (source.getHeight() - size) / 2;

   Bitmap squaredBitmap = Bitmap.createBitmap(source, x, y, size, size);
   if (!squaredBitmap.equals(source)) {
       source.recycle();
   }

   Bitmap bitmap = Bitmap.createBitmap(size, size, source.getConfig());

   Canvas canvas = new Canvas(bitmap);
   Paint paint = new Paint();
   BitmapShader shader = new BitmapShader(squaredBitmap, BitmapShader.TileMode.CLAMP, BitmapShader.TileMode.CLAMP);
   paint.setShader(shader);
   paint.setAntiAlias(true);

   float radius = size / 2f;
   canvas.drawCircle(radius, radius, radius, paint);

   squaredBitmap.recycle();
   return bitmap;
}

 

This method returns a bitmap that we shall use to add to the appropriate view holder.

Step – 4 : Also return a string called “circle” from the key() method.

@Override
public String key() {
   return "circle";
}

 

Step – 5 : Now, add this transformation to the code, where the skill image is set into the appropriate view holder using Picasso.

fun setSkillsImage(skillData: SkillData, imageView: ImageView) {
   Picasso.with(imageView.context)
           .load(getImageLink(skillData))
           .error(R.drawable.ic_susi)
           .transform(CircleTransform())
           .fit()
           .centerCrop()
           .into(imageView)
}

 

Now, all skill images will be circular, as can be seen in the following screenshot :

 .     

The first image shows the skills image before applying CircleTransform while the second image shows the same after applying it.

Resources

Continue Reading

Adding support for rich text in Eventyay Organizer App

The Open Event Organizer App provides the users with its one of the core features of the ability to create or update an event. To add this feature, we will use HTML and android’s WebView in order to aid us integrate support for rich text in the app.

The first step is adding the Wasabeef RichText library to your project. Open your build.gradle file and add the support library to the dependency section.

Adding the dependency in build.gradle(app-level) in the project:

dependencies {
   //Other dependencies
   //Rich text editor
   implementation “jp.wasabeef:richeditor-android:1.2.2”
}

What we need is an activity accessible throughout the project for any element that needs to input rich text, which means that this activity goes in the utils package.

Let’s start with building this activity:

We need to first set a hint text or a placeholder text for the editor in case there’s no saved text for the concerned field.

public class RichEditorActivity extends AppCompatActivity {
   

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {
       

       binding.editor.setPlaceholder(getString(R.string.enter_text));
       Intent intent = getIntent();
       if (intent != null) {
         description = intent.getStringExtra(TAG_RICH_TEXT);
         if (!TextUtils.isEmpty(description) && !description.equals(getString(R.string.describe_event))) {
           binding.editor.setHtml(description);
         }
       }
}

Now let’s see how we add the formatted text to our WebView. Currently we are supporting the options:

  • Undo
  • Redo
  • Bold
  • Italic
  • StrikeThrough
  • Bulleted list
  • Numbered list

as follows:

binding.actionUndo.setOnClickListener(v > binding.editor.undo());
binding.actionRedo.setOnClickListener(v > binding.editor.redo());
binding.actionBold.setOnClickListener(v > binding.editor.setBold());
binding.actionItalic.setOnClickListener(v > binding.editor.setItalic());
binding.actionStrikethrough.setOnClickListener(v > binding.editor.setStrikeThrough());
binding.actionInsertBullets.setOnClickListener(v > binding.editor.setBullets());
binding.actionInsertNumbers.setOnClickListener(v > binding.editor.setNumbers());

To add support for adding links, we need to first setup a click listener and show a dialog on click:

binding.actionInsertLink.setOnClickListener(v -> {
   if (linkDialog == null) {
       createLinkDialog();
   }
   linkDialog.show();
});

The above listener is using a linkDialog, which is initialized in the method below, as follows:

We are first dynamically creating a LinearLayout, and then 2 EditTextViews, and then adding those views to the LinearLayout. Finally we build the AlertDialog as usual and set the Dialog’s view to the LinearLayout we created.

private void createLinkDialog() {
   LinearLayout layout = new LinearLayout(this);
   layout.setOrientation(LinearLayout.VERTICAL);
   final EditText text = new EditText(this);
   text.setHint(getString(R.string.text));
   layout.addView(text);
   final EditText link = new EditText(this);
   link.setHint(getString(R.string.insert_url));
   layout.addView(link);

   linkDialog = new AlertDialog.Builder(this)
      .setPositiveButton(getString(R.string.create), (dialog, which) -> {
           binding.editor.insertLink(link.getText().toString(), text.getText().toString());
      })
      .setNegativeButton(getString(R.string.cancel), (dialog, which) -> {
          dialog.dismiss();
      })
      .setView(layout)
      .create();
}

Here’s how the result looks like:

Resources

Continue Reading
Close Menu
%d bloggers like this: