Using AsyncTask to load image files from internal storage - android

I am attempting to load picture files in the form of thumbnails from my internal storage to a list view. Currently, I am using a ViewHolder, but the loading is still choppy when scrolling so I am going to try to use an AsyncTask. However I can't get my head around how to structure the AsyncTask as most of the examples I've found deal with downloading from a website. I'm not even sure if I should subclass it in my BaseAdapter or in my MainActivity. I have added my baseadapter below with the unfinished AsyncTask at the bottom. How do I structure this to either: use the AsyncTask to assist the ViewHolder, or directly pass an image to AsyncTask and have it return the bitmap so the ListView will scroll smoothly?
public class ListViewAdapter extends BaseAdapter {
private static final int WIDTH = 250;
private static final int HEIGHT = 250;
private static final int ROTATION = 90;
private final static String TAG = "Pictures";
private final ArrayList<SelfieObject> mItems = new ArrayList<SelfieObject>();
private Context mContext;
private File mStorageDir;
private String mFilePrefix;
public ListViewAdapter(Context context, File storageDir, String filePrefix) {
mContext = context;
mStorageDir = storageDir;
mFilePrefix = filePrefix;
//get file list from storage to display
InitializeItemsFromStorage(storageDir, filePrefix);
}
//this method creates an array of files stored on the device or SD card.
private void InitializeItemsFromStorage(File storageDir, String prefix) {
log("in InitializeItemsFromStorage()");
mItems.clear();
File[] files = getFiles(storageDir, prefix);
for (File f : files) {
SelfieObject selfie = new SelfieObject(f);
mItems.add(selfie);
}
}
public void Update() {
log("in Update()");
InitializeItemsFromStorage(mStorageDir, mFilePrefix);
notifyDataSetChanged();
}
/*
* return the list of file objects of the given directory that begin with
* the prefix.
*/
private File[] getFiles(File storageDir, final String prefix) {
FileFilter fileFilter = new FileFilter() {
#Override
public boolean accept(File pathname) {
if (pathname.isFile() && pathname.getName().startsWith(prefix))
return true;
else
return false;
}
};
File[] result = storageDir.listFiles(fileFilter);
return result;
}
public int getCount() {
log("in getCount()");
return mItems.size();
}
public Object getItem(int position) {
log("in getItem()");
return mItems.get(position);
}
public long getItemId(int position) {
log("in getItemId()");
return position;
}
#Override
public View getView(int position, View convertView, ViewGroup parent) {
Log.v(TAG, "in getView for position " + position +
", convertView is " +
((convertView == null)?"null":"being recycled"));
View newView = convertView;
ViewHolder holder;
if (null == convertView) {
LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
newView = inflater.inflate(R.layout.single_item, null);
holder = new ViewHolder();
holder.description = (TextView) newView.findViewById(R.id.textView1);
holder.picture = (ImageView) newView.findViewById(R.id.imageView1);
newView.setTag(holder);
} else {
holder = (ViewHolder) newView.getTag();
}
holder.picture.setScaleType(ImageView.ScaleType.CENTER_CROP);
SelfieObject selfie = (SelfieObject) getItem(position);
setPic(holder.picture, new Point(WIDTH, HEIGHT), selfie.getPath());
TextView textView = (TextView) holder.description;
textView.setText(selfie.getName());
log("Exiting getView");
return newView;
}
static class ViewHolder {
ImageView picture;
TextView description;
}
public void add(SelfieObject listItem) {
mItems.add(listItem);
notifyDataSetChanged();
}
public ArrayList<SelfieObject> getList(){
return mItems;
}
public void removeAllViews(){
mItems.clear();
this.notifyDataSetChanged();
}
public static void setPic(ImageView imageView, Point requestedSize,
String pathName) {
// set the dimensions of the View
int targetW = requestedSize.x;
int targetH = requestedSize.y;
// Get the dimensions of the bitmap
BitmapFactory.Options bmOptions = new BitmapFactory.Options();
bmOptions.inJustDecodeBounds = true;
BitmapFactory.decodeFile(pathName, bmOptions);
int photoW = bmOptions.outWidth;
int photoH = bmOptions.outHeight;
// Determine how much to scale down the image
int scaleFactor = Math.min(photoW / targetW, photoH / targetH);
// Decode the image file into a Bitmap sized to fill the View
bmOptions.inJustDecodeBounds = false;
bmOptions.inSampleSize = scaleFactor;
bmOptions.inPurgeable = true;
Bitmap bitmap = BitmapFactory.decodeFile(pathName, bmOptions);
imageView.setImageBitmap(bitmap);
imageView.setRotation(ROTATION);
}
//Automation logging tool
public void log(String s){
Log.i(TAG, s);
}
private class AsyncTaskLoadImage extends AsyncTask<Object, Void, Bitmap>{
private ImageView image;
private String path;
public AsyncTaskLoadImage(ImageView image){
this.image = image;
this.path = image.getTag().toString();
}
#Override
protected Bitmap doInBackground(Object... params) {
Bitmap bitmap = null;
File file = new File(Environment.getExternalStorageDirectory().getAbsolutePath() + path);
if(file.exists()){
bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());
}
return bitmap;
}
}
}

The AsyncTask should do whatever is too slow to do in the UI thread. In this example, fetching and downsampling the image, and setting up the ViewHolder should be done in the background.
However, I suggest you do not try and fix the ListView by yourself, but rather have a look at already existing solutions, like: https://github.com/lucasr/smoothie
Also, I highly suggest you downsample your bitmaps, otherwise they will consume a lot of excess computing time and memory. While the previous can lag your UI when scrolling, the latter will get you a nice OutOfMemoryException. See: http://developer.android.com/training/displaying-bitmaps/cache-bitmap.html

Related

Smooth scroll on listview with calllogs

I have a custom adapter added to listview. Data is call logs from phone. I reduce list by show only records from 3 days. Problem is that when I try to scroll listview from top to bottom I have a huge lags. My Scroll isn't smooth. Is there any way to make listview scroll smoother?
Here is my custom adapter:
public class CallListAdapter extends ArrayAdapter<CallList> {
Activity activity;
public CallListAdapter(Context context, ArrayList<CallList> calls, Activity activity) {
super(context, 0, calls);
this.activity = activity;
}
#Override
public View getView(final int position, View convertView, ViewGroup parent) {
final CallList callList = getItem(position);
int actualPosition = 0;
if (convertView == null) {
convertView = LayoutInflater.from(getContext()).inflate(R.layout.call_list, parent, false);
}
final TextView call1 = convertView.findViewById(R.id.callNumber);
final TextView call2 = convertView.findViewById(R.id.callDate);
final TextView call3 = convertView.findViewById(R.id.conversationTime);
final TextView call4 = convertView.findViewById(R.id.callType);
final Button callView = convertView.findViewById(R.id.getViewName);
final ImageView bio = convertView.findViewById(R.id.lookBio);
final ImageView edit = convertView.findViewById(R.id.edit_call);
final ImageView block = convertView.findViewById(R.id.blockCall);
final ImageView call = convertView.findViewById(R.id.callUser);
final TextView bioLabel = convertView.findViewById(R.id.BioLabelSug);
final TextView editLabel = convertView.findViewById(R.id.NoteLabel);
final TextView blockLabel = convertView.findViewById(R.id.BlockLabelSug);
final TextView callLabel = convertView.findViewById(R.id.CallLabelSug);
final ConstraintLayout callContainer = convertView.findViewById(R.id.contact_container);
final ConstraintLayout bioContainer = convertView.findViewById(R.id.bio_container);
final ConstraintLayout blockContainer = convertView.findViewById(R.id.ignore_container);
final ConstraintLayout noteContainer = convertView.findViewById(R.id.note_container);
final TextView btnMarg = convertView.findViewById(R.id.buttonMargin);
final TextView callListNr2 = convertView.findViewById(R.id.callNumber2);
final LayoutInflater factory = activity.getLayoutInflater();
final View fullView = factory.inflate(R.layout.fragment_calls, null);
final RelativeLayout loading = fullView.findViewById(R.id.loadingBar);
String[] jsonData = new manageCalls().intentCallValues(position);
StringBuilder builder = new StringBuilder();
for (String s : jsonData) {
builder.append(s + "\n");
}
String str = builder.toString();
final String num = jsonData[0];
final String dat = jsonData[1];
final String typeCall = jsonData[2];
final String dur = jsonData[3];
final String authToken = SaveSharedPreferences.getPrefTokenName(getContext());
final Animation slideUp = AnimationUtils.loadAnimation(getContext(), R.anim.slideup);
final Animation slideDown = AnimationUtils.loadAnimation(getContext(), R.anim.slidedown);
final Handler handler = new Handler();
callView.setOnClickListener(new View.OnClickListener() {
#Override
public void onClick(View view) {
if (bioContainer.getVisibility() == View.GONE) {
callListNr2.setVisibility(View.GONE);
bio.setVisibility(View.VISIBLE);
bioLabel.setVisibility(View.VISIBLE);
edit.setVisibility(View.VISIBLE);
editLabel.setVisibility(View.VISIBLE);
} else if (bioContainer.getVisibility() == View.VISIBLE) {
handler.postDelayed(new Runnable() {
#Override
public void run() {
bio.setVisibility(View.GONE);
callContainer.setVisibility(View.GONE);
bioContainer.setVisibility(View.GONE);
noteContainer.setVisibility(View.GONE);
blockContainer.setVisibility(View.GONE);
}
}, 300);
}
}
});
if (actualPosition != position) {
if (bioContainer.getVisibility() == View.VISIBLE) {
bioContainer.setVisibility(View.GONE);
callContainer.setVisibility(View.GONE);
noteContainer.setVisibility(View.GONE);
blockContainer.setVisibility(View.GONE);
}
actualPosition = position;
}
call.setOnClickListener(new View.OnClickListener() {
#Override
public void onClick(View view) {
call.setEnabled(false);
loading.setVisibility(View.VISIBLE);
Intent intentCall = new Intent(view.getContext(), CallUserActivity.class);
intentCall.putExtra("number", num);
intentCall.putExtra("authToken", authToken);
intentCall.putExtra("Date", dat);
activity.startActivityForResult(intentCall, position);
handler.postDelayed(new Runnable() {
#Override
public void run() {
call.setEnabled(true);
loading.setVisibility(View.GONE);
}
}, 1000);
}
});
call2.setText(callList.callDate);
call3.setText(callList.conversationTime);
call4.setText(callList.callType);
return convertView;
}
}
Try use ViewHolder and use AsyncTask to load bitmap.
You can try this way.
private static class ViewHolder {
public TextView call1;
public TextView call2;
public TextView call3;
public TextView call4;
public Button callView;
public ImageView bio;
public ImageView edit;
public ImageView block;
public ImageView call;
public TextView bioLabel;
public TextView editLabel;
public TextView blockLabel;
public TextView callLabel;
public ConstraintLayout callContainer;
public ConstraintLayout bioContainer;
public ConstraintLayout blockContainer;
public ConstraintLayout noteContainer;
public TextView btnMarg;
public TextView callListNr2;
}
#Override
public View getView(int position, View convertView, ViewGroup parent) {
ViewHolder holder;
if (convertView == null) {
// inflate the layout
LayoutInflater inflater = LayoutInflater.from(getContext());
convertView = inflater.inflate(layoutResourceId, parent, false);
holder = new ViewHolder();
holder.call1 = convertView.findViewById(R.id....);
holder.call2 = convertView.findViewById(R.id....);
//Same for all other views
convertView.setTag(holder);
} else {
holder = (ViewHolder) convertView.getTag();
}
holder.call1.setText(....);
//Lazy load for bitmap
loadBitmap(yourFileName..., bio)
return convertView;
}
static class BitmapWorkerTask extends AsyncTask<String, Void, Bitmap> {
private final WeakReference<ImageView> imageViewReference;
BitmapWorkerTask(ImageView imageView) {
// Use a WeakReference to ensure the ImageView can be garbage collected
imageViewReference = new WeakReference<ImageView>(imageView);
}
// Decode image in background.
#Override
protected Bitmap doInBackground(String... params) {
return decodeSampledBitmapFromResource(params[0], 300, 300);
}
// Once complete, see if ImageView is still around and set bitmap.
#Override
protected void onPostExecute(Bitmap bitmap) {
if (imageViewReference != null && bitmap != null) {
final ImageView imageView = imageViewReference.get();
if (imageView != null) {
imageView.setImageBitmap(bitmap);
}
}
}
}
public void loadBitmap(String fileName, ImageView imageView) {
BitmapWorkerTask task = new BitmapWorkerTask(imageView);
task.execute(fileName);
}
public static Bitmap decodeSampledBitmapFromResource(String fileName,
int reqWidth, int reqHeight) {
// First decode with inJustDecodeBounds=true to check dimensions
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(fileName, options);
// Calculate inSampleSize
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
// Decode bitmap with inSampleSize set
options.inJustDecodeBounds = false;
return BitmapFactory.decodeFile(fileName, options);
}
Your getview is HUGE.
Your (if convertview==null) has basically almost no effect as you're setting up the view again anyways.
What you need to do is refactor the getview to not be so slow. one thing you can do is create a class that has all the findviews done already for you and put that then in the .tag of the converted view. change your onclicks to use that as well, in a manner where you don't have to recreate them(other ways to do that exist as well).
ideally your code for if you have a converted view already should be just the .settexts().
depending on the size of your list, you could just get away with creating a view for each callist and avoid recycling the converted views alltogether, in such case you could just create them in advance.
also depending on the size of your list you could just get away with creating a just a simple linearlayout instead inside a scrollview. if your list isn't huge and it's not for some old phones, it works just fine as well (Don't knock on this as bad advice until you try on your phone how huge it can be before a listview starts making more sense).

How to set captured image in ListView?

I have an ArrayList which is bound to the listview, the custom row has a textview and an imageview, now when I click on any row I have given two functionality
1. Toast message to display position: which is displaying properly.
2. Open camera to capture an image and set that particular image to the row which was clicked.
Now the problem which I am facing is :
The image gets set but always to the last row and not to the row where it was clicked and when I click on the last row to capture image while setting the image it says IndexOutofBoundException
The code I have tried :
public class DocumentsKYCAdapter extends BaseAdapter{
private Context mContext;
private ArrayList<DocumentItem> gridName;
ArrayList<Integer> selectedDocumentId = new ArrayList<Integer>();
ArrayList<String> selectedDocumentNames = new ArrayList<String>();
ArrayList<Bitmap> selectedImages = new ArrayList<Bitmap>();
private Bitmap[] gridImage;
Documents_KYC activity;
public static final int MEDIA_TYPE_IMAGE = 1;
private static final String IMAGE_DIRECTORY_NAME = "Imageview";
private Uri fileUri;
ImageView imageView;
public static byte[] b;
public static String encodedImageStr1;
int imageCapturedPosition;
public DocumentsKYCAdapter(Documents_KYC activity,
ArrayList<DocumentItem> gridName,ArrayList<Integer>
selectedDocumentId,ArrayList<String> selectedDocumentNames) {
this.activity = activity;
this.gridImage = gridImage;
this.gridName = gridName;
this.selectedDocumentNames = selectedDocumentNames;
this.selectedDocumentId = selectedDocumentId;
}
#Override
public int getCount() {
return selectedDocumentNames.size();
}
#Override
public Object getItem(int position) {
return null;
}
#Override
public long getItemId(int position) {
return 0;
}
#Override
public View getView(final int position, View convertView, ViewGroup
parent) {
View grid;
if (convertView == null) {
LayoutInflater inflater = (LayoutInflater) activity
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
grid = inflater.inflate(R.layout.documents_kyc_row, null);
} else {
grid = (View) convertView;
}
final String documentItemName =
selectedDocumentNames.get(position);
final int documentItemId = selectedDocumentId.get(position);
TextView textView = (TextView) grid.findViewById(R.id.gridName);
imageView = (ImageView) grid.findViewById(R.id.gridImage);
imageView.setTag(position);
textView.setText(documentItemName);
imageView.setOnClickListener(new View.OnClickListener() {
#Override
public void onClick(View view) {
imageCapturedPosition = position;
Toast.makeText(activity,"Id"+documentItemId+"
,Position"+imageCapturedPosition,Toast.LENGTH_LONG).
show();
imageView.getTag(position);
captureImage();
}
});
return grid;
}
private void captureImage() {
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
fileUri = getOutputMediaFileUri(MEDIA_TYPE_IMAGE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri);
activity.startActivityForResult(intent, MEDIA_TYPE_IMAGE);
}
public void onActivityResult(int requestCode, int resultCode, Intent
data) {
try {
if (requestCode == MEDIA_TYPE_IMAGE && resultCode ==
activity.RESULT_OK) {
BitmapFactory.Options options = new
BitmapFactory.Options();
options.inSampleSize = 1;
Bitmap bitmap = Utility.decodeFile(fileUri.getPath(),
options);
FileOutputStream out = null;
try {
out = new FileOutputStream(fileUri.getPath());
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out);
ByteArrayOutputStream baos = new
ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, 80,
baos);
b = baos.toByteArray();
encodedImageStr1 = Base64.encodeToString(b,
Base64.DEFAULT);
} catch (FileNotFoundException e) {
e.printStackTrace();
}
selectedImages.add(bitmap);
imageView.setImageBitmap(selectedImages.
get(imageCapturedPosition));
} else if (resultCode == activity.RESULT_CANCELED) {
Toast.makeText(activity,
"User cancelled image capture",
Toast.LENGTH_SHORT)
.show();
} else {
Toast.makeText(activity,
"Sorry! Failed to capture image",
Toast.LENGTH_SHORT)
.show();
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
Custom Class :
public class BitmapModel {
Bitmap imageId;
ArrayList<String> documentNamesList;
public ArrayList<String> getDocumentNamesList() {
return documentNamesList;
}
public void setDocumentNamesList(ArrayList<String> documentNamesList) {
this.documentNamesList = documentNamesList;
}
public Bitmap getImageId() {
return imageId;
}
public void setImageId(Bitmap imageId) {
this.imageId = imageId;
}
}
Your issue is in the line -
selectedImages.add(bitmap);
Here whenever you add an image to your arrayList it always adds at the last position and then when you do imageView.setImageBitmap(selectedImages.get(imageCapturedPosition)); It tries to get the value for the position you selected which is not necessarily the last position.
The better way for you to do is create a Custom Class with Bitmap and selectedDocumentNames as part of the class then each object of the class would represent a name and an image associated with it.
Now when you capture the image, assign the image to the class Bitmap and then populate your listview with that Bitmap.
The workaround for your present code code be to either add the image into a particular position in array denoted by imageCapturedPosition or create a hashmap of type position,bitmap and then store it with the selected position. though i would not recommend any of these workarounds as they would cause other problems in future like memory leaks and positional movements in arrays etc and you would have to take care of these things

Spinner, gridview, asynchronous display

Background
Hi, I'm relatively new to android programming. I'm still learning.
I have a database with two tables : Item and Type. Item has the following columns : _id, code, type. Type has the following columns : _id, name.
In my activity, I display a spinner of the types, and a gridview of the items. The spinner is supposed to filter the results and update the gridview.
The gridview items only consist in images. The column code gives the name of the image file.
I have a database helper DatabaseHelper, that opens, close, and access the database in different ways.
At the start of my activity ItemList, getContents() is called, getContents() calls the database, and updates listOfIds and listOfCodes according to the value of selectedType (initialised to 0). Then the gridview is created.
For the gridview, I had memory issues while displaying images and scrolling (out of memory). So I followed the Android tutorial : https://developer.android.com/training/displaying-bitmaps/process-bitmap.html Now, each item is displayed asynchronously. And it works fine.
Issue
When I select a type in the spinner, selectedType is updated, getContents() is called and then notifyDataSetChanged() is called. Sometimes it works fine, sometimes it crashes. I think that when I select a type, old threads that have not been terminated are accessing listOfIds and listOfCodes while they are being updated.
How to kill all of thoses threads (before updating list) and prevent the gridview from calling getView during the update?
I was thinking about creating a list containing the threads, update it, and kill all threads before updating the lists. But I can't find the right way to prevent the gridview's adapter from creating views during the update.
Thank you for your help.
Code
Here are parts of my code :
ItemList.java
public class ItemList extends Activity {
private ImageAdapter mAdapter;
private Bitmap mPlaceHolderBitmap;
private DatabaseHelper myHelper;
public static int column = 3;
public static int MARGIN = 5; //margin in dp
public static int width;
public GridView gallery;
public SpinnerAdapter spinnerAdapter;
public Spinner mySpinner;
public int defaultImageID;
#Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
setContentView(R.layout.item_list);
initialiseValues();
createHelper();
resetPositions();
getContents();
createLists();
createSpinnerListener();
}
#Override
protected void onResume() {
super.onResume();
}
public void createHelper() {
myHelper = new DatabaseHelper(getApplicationContext());
myHelper.getSpinnerIds();
}
public void initialiseValues(){
myHelper.selectedType = 0;
//determines the width of the displayed image
Point size = new Point();
Display display = getWindowManager().getDefaultDisplay();
display.getSize(size);
width = size.x - dpToPx(2*column*MARGIN);
//determines the default image to display
defaultImageID = ...; //here I get the id of the default image;
mPlaceHolderBitmap = decodeSampledBitmapFromResource(getResources(), defaultImageID, null, null), width, width);
}
public void createList() {
spinnerAdapter = new SpinnerAdapter(this, myHelper, myHelper.spinnerNames);
spinnerAdapter.setDropDownViewResource(R.layout.spinner_item);
mySpinner = (Spinner) findViewById(R.id.spinner);
mySpinner.setAdapter(spinnerAdapter);
gallery = (GridView) findViewById(R.id.gallery);
mAdapter = new ImageAdapter(this);
gallery.setAdapter(mAdapter);
}
public void getContents() {
myHelper.getLists();
}
public void createSpinnerListener() {
mySpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
#Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
myHelper.selectedType = myHelper.spinnerIds.get(position);
getContents();
mAdapter.notifyDataSetChanged();
}
#Override
public void onNothingSelected(AdapterView<?> parent) {
}
});
}
public int dpToPx(int dp) {
DisplayMetrics displayMetrics = this.getResources().getDisplayMetrics();
return Math.round(dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT));
}
private class ImageAdapter extends BaseAdapter {
private final Context mContext;
public ImageAdapter(Context context) {
super();
mContext = context;
}
#Override
public int getCount() {
return myHelper.listOfCodes.size();
}
#Override
public Object getItem(int position) {
return null;
}
#Override
public long getItemId(int position) {
return position;
}
#Override
public View getView(int position, View convertView, ViewGroup container) {
View view;
if (convertView == null) { // if it's not recycled, initialize some attributes
LayoutInflater inflater = (LayoutInflater) getApplicationContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
view = inflater.inflate(R.layout.grid_item, null);
}
else {view = convertView;}
int image_id = getApplicationContext().getResources().getIdentifier(".../" + myHelper.listOfCodes.get(position), null, null);
if (image_id == 0) {image_id = R.drawable.item_unknown;}
loadBitmap(image_id, imageView);
return view;
}
}
public void loadBitmap(int resId, ImageView imageView) {
if (cancelPotentialWork(resId, imageView)) {
final BitmapWorkerTask task = new BitmapWorkerTask(imageView);
final AsyncDrawable asyncDrawable = new AsyncDrawable(getResources(), mPlaceHolderBitmap, task);
imageView.setImageDrawable(asyncDrawable);
task.execute(resId);
}
}
public static boolean cancelPotentialWork(int data, ImageView imageView) {
final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);
if (bitmapWorkerTask != null) {
final int bitmapData = bitmapWorkerTask.data;
if (bitmapData != data) {
bitmapWorkerTask.cancel(true); // Cancel previous task
}
else {
return false; // The same work is already in progress
}
}
return true; // No task associated with the ImageView, or an existing task was cancelled
}
private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) {
if (imageView != null) {
final Drawable drawable = imageView.getDrawable();
if (drawable instanceof AsyncDrawable) {
final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable;
return asyncDrawable.getBitmapWorkerTask();
}
}
return null;
}
public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) {
// First decode with inJustDecodeBounds=true to check dimensions
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, resId, options);
// Calculate inSampleSize
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
// Decode bitmap with inSampleSize set
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(res, resId, options);
}
public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
// Raw height and width of image
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
final int halfHeight = height / 2;
final int halfWidth = width / 2;
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
// height and width larger than the requested height and width.
while ((halfHeight / inSampleSize) >= reqHeight
&& (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
static class AsyncDrawable extends BitmapDrawable {
private final WeakReference<BitmapWorkerTask> bitmapWorkerTaskReference;
public AsyncDrawable(Resources res, Bitmap bitmap, BitmapWorkerTask bitmapWorkerTask) {
super(res, bitmap);
bitmapWorkerTaskReference = new WeakReference<BitmapWorkerTask>(bitmapWorkerTask);
}
public BitmapWorkerTask getBitmapWorkerTask() {
return bitmapWorkerTaskReference.get();
}
}
class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
private final WeakReference<ImageView> imageViewReference;
private int data = 0;
public BitmapWorkerTask(View view) {
// Use a WeakReference to ensure the ImageView can be garbage collected
imageViewReference = new WeakReference<ImageView>(imageView);
}
// Decode image in background.
#Override
protected Bitmap doInBackground(Integer... params) {
data = params[0];
return decodeSampledBitmapFromResource(getResources(), data, width, width);
}
#Override
protected void onPostExecute(Bitmap bitmap) {
if (isCancelled()) {
bitmap = null;
}
if (imageViewReference != null && bitmap != null) {
final ImageView imageView = imageViewReference.get();
final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);
if (this == bitmapWorkerTask && imageView != null) {
imageView.setImageBitmap(bitmap);
}
}
}
}
}
DatabaseHelper.java
public class DatabaseHelper extends SQLiteOpenHelper{
...
public int currentID = 0;
public int numberOfItems = 0;
public List<Integer> listOfIds = new ArrayList<>();
public List<String> listOfCodes = new ArrayList<>();
public List<Integer> spinnerIds = new ArrayList<>();
public List<String> spinnerNames = new ArrayList<>();
public int selectedType = 0;
private Context context;
public SQLiteDatabase myDataBase;
...
public DatabaseHelper(Context context) {
super(context, DB_NAME, null, 1);
this.context = context;
}
...
public void getSpinnerIds() {
spinnerIds.clear();
spinnerNames.clear();
/*
updates spinnerIds by accessing the db, first one is "All types"
*/
}
public void getLists() {
listOfIds.clear();
listOfCodes.clear();
numberOfItems = 0;
String where = " WHERE code <> '0'";
if (selectedPosition != 0) {where += " AND Type = " + String.valueOf(selectedType);}
openDataBase();
try {
Cursor myCursor = myDataBase.rawQuery("SELECT _id, code, type FROM Item" + where + " ORDER BY _id ASC", null);
myCursor.moveToFirst();
do {
listOfIds.add(myCursor.getInt(0));
listOfCodes.add(myCursor.getString(1));
}
while (myCursor.moveToNext());
myCursor.close();
numberOfItems = listOfIds.size();
}
catch (Exception CursorIndexOutOfBoundsException) {}
myDataBase.close();
}
public String getName(int id) {
String name;
openDataBase();
Cursor myCursor = myDataBase.rawQuery("SELECT name FROM Item WHERE _id = " + String.valueOf(id), null);
myCursor.moveToFirst();
name = myCursor.getString(0);
myCursor.close();
myDataBase.close();
return name;
}
...
}
SpinnerAdapter.java
public class SpinnerAdapter extends ArrayAdapter<String> {
private Context context;
private DatabaseHelper helper;
public SpinnerAdapter(Context context, DatabaseHelper helper, List<String> list) {
super(context, R.layout.spinner_item_small, R.id.text, list);
this.context = context;
this.helper = helper;
}
#Override
public View getDropDownView(int position, View convertView, ViewGroup parent) {
return getCustomView(position, convertView);
}
#Override
public View getView(int position, View convertView, ViewGroup prnt) {
return getCustomView(position, convertView);
}
public View getCustomView(int position, View convertView) {
View view;
if (convertView == null) {
LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
view = inflater.inflate(R.layout.spinner_item_small, null);
}
else {view = convertView;}
TextView textView = (TextView) view.findViewById(R.id.text);
textView.setText(helper.spinnerNames.get(position));
return view;
}
}

Image didn't insert properly while load image from gallery to gridview with asynctask

I would like to load all image from gallery to activity with asynctask. I learn it from this link. But there was a problem that I was unable to solve yet.
When I scroll down slowly from the grid view it work perfectly fine. But When I scroll up or scroll faster the Image view either will load previous loaded image then only loaded the correct photo or it might loaded few photo randomly eventually only get to the correct photo.
Here is my source code
public class PhotoPicker extends ActionBarActivity {
ArrayList<String> mArrayList = new ArrayList<String>();
ImageAdapter myImageAdapter;
AsyncTaskLoadFiles myAsyncTaskLoadFiles;
#Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_photo_picker);
Context context = getApplicationContext();
final GridView gridview = (GridView) findViewById(R.id.gridview);
myImageAdapter = new ImageAdapter(this);
gridview.setAdapter(myImageAdapter);
myAsyncTaskLoadFiles = new AsyncTaskLoadFiles(myImageAdapter);
myAsyncTaskLoadFiles.execute();
}
#Override
public boolean onCreateOptionsMenu(Menu menu) {
// Inflate the menu; this adds items to the action bar if it is present.
getMenuInflater().inflate(R.menu.menu_photo_picker, menu);
return true;
}
#Override
public boolean onOptionsItemSelected(MenuItem item) {
// Handle action bar item clicks here. The action bar will
// automatically handle clicks on the Home/Up button, so long
// as you specify a parent activity in AndroidManifest.xml.
int id = item.getItemId();
//noinspection SimplifiableIfStatement
if (id == R.id.action_settings) {
return true;
}
return super.onOptionsItemSelected(item);
}
public class AsyncTaskLoadFiles extends AsyncTask<Void, String, Void> {
ImageAdapter myTaskAdapter;
Context context = getApplicationContext();
Cursor cur;
public AsyncTaskLoadFiles(ImageAdapter adapter) {
myTaskAdapter = adapter;
}
#Override
protected void onPreExecute() {
String[] projection = new String[]{
MediaStore.Images.Media.DATA
};
// Get the base URI for the People table in the Contacts content provider.
Uri images = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
// Make the query.
cur = context.getContentResolver().query(images,
projection, // Which columns to return
null, // Which rows to return (all rows)
null, // Selection arguments (none)
MediaStore.MediaColumns.DATE_ADDED + " DESC" // Ordering
);
super.onPreExecute();
}
#Override
protected Void doInBackground(Void... params) {
if (cur.moveToFirst()) {
String bucket;
int bucketColumn = cur.getColumnIndex(
MediaStore.Images.Media.DATA);
do {
bucket = cur.getString(bucketColumn);
publishProgress(bucket);
if (isCancelled()) break;
} while (cur.moveToNext());
}
return null;
}
#Override
protected void onProgressUpdate(String... values) {
myTaskAdapter.add(values[0]);
super.onProgressUpdate(values);
}
#Override
protected void onPostExecute(Void result) {
/*myTaskAdapter.notifyDataSetChanged();*/
super.onPostExecute(result);
}
}
public class ImageAdapter extends BaseAdapter {
private Context mContext;
public ImageAdapter(Context c) {
mContext = c;
}
void remove(int index){
mArrayList.remove(index);
}
public void add(String path){
mArrayList.add(path);
}
public int getCount() {
return mArrayList.size();
}
public Object getItem(int position) {
return mArrayList.get(position);
}
public long getItemId(int position) {
return 0;
}
class ViewHolder {
ImageView image;
int position;
}
// create a new ImageView for each item referenced by the Adapter
public View getView(final int position, View convertView, ViewGroup parent) {
ViewHolder holder;
ImageView imageView;
if (convertView == null) {
// if it's not recycled, initialize some attributes
imageView = new ImageView(mContext);
imageView.setLayoutParams(new GridView.LayoutParams(230, 230));
imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
imageView.setPadding(2, 2, 2, 2);
convertView = imageView;
holder = new ViewHolder();
holder.image = imageView;
holder.position = position;
convertView.setTag(holder);
} else {
((ImageView)convertView).setImageBitmap(null);
holder = (ViewHolder) convertView.getTag();
}
new AsyncTask<ViewHolder, Void, Bitmap>() {
private ViewHolder v;
#Override
protected Bitmap doInBackground(ViewHolder... params) {
v = params[0];
Bitmap bm = decodeSampledBitmapFromUri(mArrayList.get(position), 220, 220);
Log.d("holder", String.valueOf(position));
return bm;
}
#Override
protected void onPostExecute(Bitmap result) {
super.onPostExecute(result);
//Not work for me!
v.image.setImageBitmap(result);
}
}.execute(holder);
//imageView.setImageBitmap(bm);
//return imageView;
return convertView;
}
}
public Bitmap decodeSampledBitmapFromUri(String path, int reqWidth,
int reqHeight) {
Bitmap bm = null;
// First decode with inJustDecodeBounds=true to check dimensions
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(path, options);
// Calculate inSampleSize
options.inSampleSize = calculateInSampleSize(options, reqWidth,
reqHeight);
// Decode bitmap with inSampleSize set
options.inJustDecodeBounds = false;
bm = BitmapFactory.decodeFile(path, options);
return bm;
}
public int calculateInSampleSize(
BitmapFactory.Options options, int reqWidth, int reqHeight) {
// Raw height and width of image
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
if (width > height) {
inSampleSize = Math.round((float) height
/ (float) reqHeight);
} else {
inSampleSize = Math.round((float) width / (float) reqWidth);
}
}
return inSampleSize;
}
}
Apologies if my english make it confuse the link that I provide have 2 youtube video the second video also have the problem that I mention earlier.Any help really very grateful
It is because GridView (ListView, RecyclerView also) is re-using already existing views. When row view moved out of the screen GridView is re-using this view for another data.
In your example, when you call v.image.setImageBitmap(result); it may change image on view that is already used for another data item.
To fix this you should check if view is still related to your data model, e.g.
public View getView(final int position, final View convertView, ViewGroup parent) {
...
holder.image.setTag(position);
new AsyncTask<ViewHolder, Void, Bitmap>() {
...
#Override
protected void onPostExecute(Bitmap result) {
if(v.image.getTag() == position) {
v.image.setImageBitmap(result);
}
}
}
}

Android: LruCache issue with Bitmap

I have a global bitmap cache using LruCache class. when loading thumbnails for the listview, the cache is used first. It works just OK.
But one issue is: sometimes the Bitmap instance from the cache cannot be displayed on the listview. it seems such bitmap from cache is not valid any more. I have checked the bitmap from cache if it is not null and if it is not recycled, but it still seems such bitmap cannot be displayed (even it is not null and it is not recycled).
The cache class:
public class ImageCache {
private LruCache<String, Bitmap> mMemoryCache;
private static ImageCache instance;
public static ImageCache getInstance() {
if(instance != null) {
return instance;
}
instance = new ImageCache();
instance.initializeCache();
return instance;
}
protected void initializeCache() {
final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
// Use 1/8th of the available memory for this memory cache.
final int cacheSize = maxMemory / 8;
mMemoryCache = new LruCache<String, Bitmap>(cacheSize) {
#Override
protected int sizeOf(String key, Bitmap bitmap) {
// The cache size will be measured in kilobytes rather than
// number of items.
return bitmap.getByteCount() / 1024;
}
};
}
public Bitmap getImage(String url) {
return this.mMemoryCache.get(url);
}
public void cacheImage(String url, Bitmap image) {
this.mMemoryCache.put(url, image);
}
}
and the code to use the cache is in the Adapter class which is subclass of CursorAdapter:
final ImageCache cache = ImageCache.getInstance();
// First get from memory cache
final Bitmap bitmap = cache.getImage(thumbnailUrl);
if (bitmap != null && !bitmap.isRecycled()) {
Log.d(TAG, "The bitmap is valid");
viewHolder.imageView.setImageBitmap(bitmap);
}
else {
Log.d(TAG, "The bitmap is invalid, reload it.");
viewHolder.imageView.setImageResource(R.drawable.thumbnail_small);
// use the AsyncTask to download the image and set in cache
new DownloadImageTask(context, viewHolder.imageView, thumbnailUrl, dir, filepath).execute();
}
the code of DownloadImageTask:
public class DownloadImageTask extends AsyncTask<String, Void, Bitmap> {
private ImageView mImageView;
private String url;
private String dir;
private String filename;
private Context context;
public DownloadImageTask(Context context, ImageView imageView, String url, String dir, String filename) {
this.mImageView = imageView;
this.url = url;
this.filename = filename;
this.dir = dir;
this.context = context;
this.cache = cache;
}
protected Bitmap doInBackground(String... urls) {
// String urldisplay = urls[0];
final Bitmap bitmap = FileUtils.readImage(context, dir, filename, url);
return bitmap;
}
protected void onPostExecute(Bitmap result) {
final ImageCache cache = ImageCache.getInstance();
if(result != null) {
cache.put(url, result);
mImageView.setImageBitmap(result);
}
}
}
any help will be appreciated. Thanks!
Updates: I have followed the link suggested by greywolf82: section "Handle Configuration Changes". I put the following attribute in my activity class and the two fragment classes:
public LruCache mMemoryCache;
In the activity class, I try to initialize the cache when calling the fragment:
// Get the cache
mMemoryCache = mIndexFragment.mRetainedCache;
if (mMemoryCache == null) {
final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
// Use 1/8th of the available memory for this memory cache.
final int cacheSize = maxMemory / 8;
// Initialize the cache
mMemoryCache = new LruCache<String, Bitmap>(cacheSize) {
#Override
protected int sizeOf(String key, Bitmap bitmap) {
// The cache size will be measured in kilobytes rather than
// number of items.
return bitmap.getByteCount() / 1024;
}
};
Log.d(TAG, "Initialized the memory cache");
mIndexFragment.mRetainedCache = mMemoryCache;
}
in the fragment class:
setRetainInstance(true);
and I pass the cache instance to the adapter constructor so that the adapter can use the cache.
but I still got the same issue.
Update 2:
the two adapter classes with changes to accept the LruCache instance:
NewsCursorAdapter:
public class NewsCursorAdapter extends CursorAdapter {
private static final String TAG = "NewsCursorAdapter";
private LruCache<String, Bitmap> cache;
private Context mContext;
public NewsCursorAdapter(Context context, LruCache<String, Bitmap> cache) {
super(context, null, false);
this.mContext = context;
this.cache = cache;
}
#Override
public void bindView(View view, Context context, Cursor cursor) {
final Setting setting = ApplicationContext.getSetting();
// Get the view holder
ViewHolder viewHolder = (ViewHolder) view.getTag();
final String thumbnail = cursor.getString(NewsContract.Entry.THUMBNAIL_CURSOR_INDEX);
if(thumbnail != null) {
String pictureDate = cursor.getString(NewsContract.Entry.PIC_DATE_CURSOR_INDEX);
final String dir = "thumbnails/" + pictureDate + "/";
final String filepath = thumbnail + "-small.jpg";
final String thumbnailUrl = setting.getCdnUrl() + dir + filepath;
//final ImageCache cache = ImageCache.getInstance();
// First get from memory cache
final Bitmap bitmap = cache.get(thumbnailUrl);
if (bitmap != null && !bitmap.isRecycled()) {
Log.d(TAG, "The bitmap is valid: " + bitmap.getWidth());
viewHolder.imageView.setImageBitmap(bitmap);
}
else {
Log.d(TAG, "The bitmap is invalid, reload it.");
viewHolder.imageView.setImageResource(R.drawable.thumbnail_small);
new DownloadImageTask(viewHolder.imageView, thumbnailUrl, dir, filepath).execute();
}
}
else {
viewHolder.imageView.setVisibility(View.GONE);
}
}
#Override
public View newView(Context context, Cursor cursor, ViewGroup parent) {
LayoutInflater inflater = (LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View view = inflater.inflate(R.layout.listview_item_row, parent,
false);
// Initialize the view holder
ViewHolder viewHolder = new ViewHolder();
viewHolder.titleView = (TextView) view.findViewById(R.id.title);
viewHolder.timeView = (TextView) view.findViewById(R.id.news_time);
viewHolder.propsView = (TextView) view.findViewById(R.id.properties);
viewHolder.imageView = (ImageView) view.findViewById(R.id.icon);
view.setTag(viewHolder);
return view;
}
static class ViewHolder {
TextView titleView;
TextView timeView;
TextView propsView;
ImageView imageView;
}
private class DownloadImageTask extends AsyncTask<String, Void, Bitmap> {
private ImageView mImageView;
private String url;
private String dir;
private String filename;
public DownloadImageTask(ImageView imageView, String url, String dir, String filename) {
this.mImageView = imageView;
this.url = url;
this.filename = filename;
this.dir = dir;
}
protected Bitmap doInBackground(String... urls) {
final Bitmap bitmap = FileUtils.readImage(mContext, dir, filename, url);
return bitmap;
}
protected void onPostExecute(Bitmap result) {
//final ImageCache cache = ImageCache.getInstance();
if(result != null) {
cache.put(url, result);
mImageView.setImageBitmap(result);
}
}
}
}
the list adapter, NewsTopicItemAdapter:
public class NewsTopicItemAdapter extends ArrayAdapter<NewsTopicItem> {
private Context context = null;
private EntryViewHolder viewHolder;
private HeaderViewHolder headerViewHolder;
private LruCache<String, Bitmap> mCache;
public NewsTopicItemAdapter(Context context, List<NewsTopicItem> arrayList, LruCache<String, Bitmap> cache) {
super(context, 0, arrayList);
this.context = context;
this.mCache = cache;
}
public void setItems(List<NewsTopicItem> items) {
this.addAll(items);
}
#Override
public View getView(int position, View convertView, ViewGroup parent) {
final NewsTopicItem item = getItem(position);
View view;
if(!item.isHeader()) {
view = this.getEntryView((NewsTopicEntry)item, convertView, parent);
}
else {
view = this.getHeaderView((NewsTopicHeader)item, convertView, parent);
}
return view;
}
protected View getEntryView(NewsTopicEntry newsItem, View convertView, ViewGroup parent) {
View view;
LayoutInflater inflater = (LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
viewHolder = new EntryViewHolder();
view = inflater.inflate(R.layout.listview_item_row, parent,
false);
// Initialize the view holder
viewHolder.titleView = (TextView) view.findViewById(R.id.title);
viewHolder.timeView = (TextView) view.findViewById(R.id.news_time);
viewHolder.propsView = (TextView) view.findViewById(R.id.properties);
viewHolder.imageView = (ImageView) view.findViewById(R.id.icon);
view.setTag(viewHolder);
viewHolder.propsView.setText(newsItem.getSource());
if (newsItem.getThumbnail() != null) {
final String dir = "thumbnails/" + newsItem.getPictureDate() + "/";
final String filepath = newsItem.getThumbnail() + "-small.jpg";
final String thumbnailUrl = "http://www.oneplusnews.com/static/" + dir + filepath;
//final ImageCache cache = ImageCache.getInstance();
// First get from memory cache
final Bitmap bitmap = mCache.get(thumbnailUrl);
if (bitmap != null && !bitmap.isRecycled()) {
viewHolder.imageView.setImageBitmap(bitmap);
} else {
viewHolder.imageView.setImageResource(R.drawable.thumbnail_small);
new DownloadImageTask(viewHolder.imageView, thumbnailUrl, dir, filepath).execute();
}
}
else {
viewHolder.imageView.setVisibility(View.GONE);
}
viewHolder.titleView.setText(newsItem.getTitle());
viewHolder.timeView.setText(DateUtils.getDisplayDate(newsItem.getCreated()));
return view;
}
protected View getHeaderView(NewsTopicHeader header, View convertView, ViewGroup parent) {
View view;
LayoutInflater inflater = (LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
headerViewHolder = new HeaderViewHolder();
view = inflater.inflate(R.layout.news_list_header, parent,
false);
// Initialize the view holder
headerViewHolder.topicView = (TextView) view.findViewById(R.id.topic);
view.setTag(headerViewHolder);
final View imageView = view.findViewById(R.id.more_icon);
imageView.setOnClickListener(new OnClickListener() {
public void onClick(View v) {
// Start the Fragement
}
});
Topic topic = header.getTopic();
if(topic.isKeyword()) {
headerViewHolder.topicView.setText(topic.getName());
}
else {
// This is a hack to avoid error with - in android
headerViewHolder.topicView.setText(ResourceUtils.getStringByName(context, topic.getName()));
}
return view;
}
private class DownloadImageTask extends AsyncTask<String, Void, Bitmap> {
private ImageView mImageView;
private String url;
private String dir;
private String filename;
public DownloadImageTask(ImageView imageView, String url, String dir, String filename) {
this.mImageView = imageView;
this.url = url;
this.filename = filename;
this.dir = dir;
}
protected Bitmap doInBackground(String... urls) {
final Bitmap mIcon11 = FileUtils.readImage(context, dir, filename, url);
return mIcon11;
}
protected void onPostExecute(Bitmap result) {
//final ImageCache cache = ImageCache.getInstance();
if(result != null) {
mCache.put(url, result);
mImageView.setImageBitmap(result);
}
}
}
static class EntryViewHolder {
TextView titleView;
TextView timeView;
TextView propsView;
ImageView imageView;
TextView topicView;
}
static class HeaderViewHolder {
TextView topicView;
}
}
Update 3: I have attached the debug information from eclipse: the 1st picture is the working bitmap, and the 2nd one is the non-working bitmap from cache. I didn't find anything suspicious.
The debug information of the working bitmap from the cache:
The debug information of the non-working bitmap from the cache:
Finally I figured out the problem. It is becuase of the adapter. in the adapter I have set some ImageView as invisible if no thumbnail is needed. when the user scrolls the list view, such ImageView instance will be reused, but the visibility is not updated.
so the cache itself is OK now. The solution is to check the visibility of the ImageView and update it if needed.
Anyway thanks a lot to greywolf82 for your time and the tip about the singleton pattern.
The singleton pattern is the evil :) Please avoid it completely and use a fragment with setReteainInstance(true) as explained here

Categories

Resources